Three-Way Matching & Invoice Verification

Level: Advanced Module: Procurement 26 min read Lesson 32 of 55

Overview

  • What you’ll learn:
    • The three-way matching concept: Purchase Order, Material Receipt, and Vendor Invoice
    • The MMatchPO API: how iDempiere links PO lines to receipt lines and invoice lines
    • The MMatchInv API: how iDempiere links receipt lines to invoice lines
    • AP invoice processing via MInvoice (IsSOTrx=false)
    • MInvoiceLine methods for copying from orders and receipts
    • Purchase Price Variance (PPV) calculation and accounting
    • Landed cost allocation through invoice lines
    • End-to-end document flow with real code examples
  • Prerequisites: Lesson 2 — Purchase Orders, Lesson 3 — Material Receipt & Goods Matching
  • Estimated reading time: 25 minutes

1. Introduction: The Three-Way Matching Concept

Three-way matching is a fundamental internal control in procurement accounting. It ensures that an organization only pays for goods it actually ordered and received. The three documents involved form a triangle:

  1. Purchase Order (PO) — what you ordered (quantity and agreed price)
  2. Material Receipt (MR) — what you physically received (quantity)
  3. Vendor Invoice (VI) — what the vendor is charging (quantity and price)

When all three documents agree on the product, quantity, and price, the invoice is considered fully verified and can be approved for payment. When they disagree, the system detects the variance and may require management approval before payment is released.

1.1 Why Three-Way Matching Matters

Without three-way matching, an organization is exposed to several risks:

  • Overpayment: Paying for goods that were never received
  • Price discrepancies: Paying more than the agreed purchase order price
  • Quantity discrepancies: Paying for more units than were physically delivered
  • Duplicate payments: Paying the same invoice twice because it could not be linked to a specific receipt
  • Inventory costing errors: Goods received but never invoiced create “Not Invoiced Receipts” that distort the balance sheet

1.2 iDempiere’s Matching Architecture

iDempiere implements three-way matching through two dedicated matching tables, each with its own document base type used for accounting purposes:

Matching Table Class DocBaseType Constant Value Links
M_MatchPO MMatchPO DOCBASETYPE_MatchPO “MXP” PO Line ↔ Receipt Line and/or Invoice Line
M_MatchInv MMatchInv DOCBASETYPE_MatchInvoice “MXI” Receipt Line ↔ Invoice Line

These constants are defined in the generated class X_C_DocType:

// From X_C_DocType (inherited by MDocType)
public static final String DOCBASETYPE_MatchPO = "MXP";
public static final String DOCBASETYPE_MatchInvoice = "MXI";
public static final String DOCBASETYPE_APInvoice = "API";
public static final String DOCBASETYPE_APCreditMemo = "APC";

Together, M_MatchPO and M_MatchInv form the complete matching picture. A M_MatchPO record can reference a PO line and a receipt line (PO-Receipt match), a PO line and an invoice line (PO-Invoice match), or all three (fully matched). A M_MatchInv record always references both a receipt line and an invoice line (Receipt-Invoice match). When all three records exist for a given product line, the three-way match is complete.


2. MInvoice for Accounts Payable

The MInvoice class serves double duty in iDempiere — it handles both AR (Accounts Receivable) invoices and AP (Accounts Payable) invoices. The critical distinction is the IsSOTrx flag. When IsSOTrx = false, the invoice is an AP invoice (vendor invoice). The class is located at:

org.adempiere.base/src/org/compiere/model/MInvoice.java

2.1 AP Document Base Types

For AP processing, two DocBaseType values are relevant:

Constant Value Description
DOCBASETYPE_APInvoice “API” Standard vendor invoice — creates a payable
DOCBASETYPE_APCreditMemo “APC” Vendor credit memo — reduces a payable

The setC_DocTypeTarget_ID(String DocBaseType) method can be used to set the target document type by base type:

// Set Target Document Type by DocBaseType
public void setC_DocTypeTarget_ID(String DocBaseType) {
    String sql = "SELECT C_DocType_ID FROM C_DocType "
        + "WHERE AD_Client_ID=? AND AD_Org_ID in (0,?) AND DocBaseType=?"
        + " AND IsActive='Y' "
        + "ORDER BY IsDefault DESC, AD_Org_ID DESC";
    int C_DocType_ID = DB.getSQLValueEx(null, sql,
        getAD_Client_ID(), getAD_Org_ID(), DocBaseType);
    // ...
    setC_DocTypeTarget_ID(C_DocType_ID);
    boolean isSOTrx = MDocType.DOCBASETYPE_ARInvoice.equals(DocBaseType)
        || MDocType.DOCBASETYPE_ARCreditMemo.equals(DocBaseType);
    setIsSOTrx(isSOTrx);
}

When no target is explicitly specified, the default method uses the SOTrx flag:

// Set Target Document Type based on SOTrx flag
public void setC_DocTypeTarget_ID() {
    if (getC_DocTypeTarget_ID() > 0)
        return;
    if (isSOTrx())
        setC_DocTypeTarget_ID(MDocType.DOCBASETYPE_ARInvoice);
    else
        setC_DocTypeTarget_ID(MDocType.DOCBASETYPE_APInvoice);
}

2.2 Constructor from MOrder (Purchase Order)

The most common way to create an AP invoice is from a purchase order. The constructor copies all relevant header fields from the order:

/**
 * Create Invoice from Order
 * @param order order
 * @param C_DocTypeTarget_ID target document type
 * @param invoiceDate date or null
 */
public MInvoice(MOrder order, int C_DocTypeTarget_ID, Timestamp invoiceDate) {
    this(order.getCtx(), 0, order.get_TrxName());
    setClientOrg(order);
    setOrder(order);    // set base settings
    //
    if (C_DocTypeTarget_ID <= 0) {
        MDocType odt = MDocType.get(order.getC_DocType_ID());
        if (odt != null) {
            C_DocTypeTarget_ID = odt.getC_DocTypeInvoice_ID();
            if (C_DocTypeTarget_ID <= 0)
                throw new AdempiereException(
                    "@NotFound@ @C_DocTypeInvoice_ID@ - @C_DocType_ID@:"
                    + odt.get_Translation(MDocType.COLUMNNAME_Name));
        }
    }
    setC_DocTypeTarget_ID(C_DocTypeTarget_ID);
    if (invoiceDate != null)
        setDateInvoiced(invoiceDate);
    setDateAcct(getDateInvoiced());
    //
    setSalesRep_ID(order.getSalesRep_ID());
    //
    setC_BPartner_ID(order.getBill_BPartner_ID());
    setC_BPartner_Location_ID(order.getBill_Location_ID());
    setAD_User_ID(order.getBill_User_ID());
}

The setOrder(MOrder order) method copies detailed settings including price list, currency, conversion type, payment rule, payment terms, PO reference, project, campaign, activity, and dimension fields:

public void setOrder(MOrder order) {
    if (order == null) return;
    setC_Order_ID(order.getC_Order_ID());
    setIsSOTrx(order.isSOTrx());
    setIsDiscountPrinted(order.isDiscountPrinted());
    setM_PriceList_ID(order.getM_PriceList_ID());
    setIsTaxIncluded(order.isTaxIncluded());
    setC_Currency_ID(order.getC_Currency_ID());
    setC_ConversionType_ID(order.getC_ConversionType_ID());
    setPaymentRule(order.getPaymentRule());
    setC_PaymentTerm_ID(order.getC_PaymentTerm_ID());
    setPOReference(order.getPOReference());
    setDescription(order.getDescription());
    setDateOrdered(order.getDateOrdered());
    // Accounting dimensions
    setAD_OrgTrx_ID(order.getAD_OrgTrx_ID());
    setC_Project_ID(order.getC_Project_ID());
    setC_Campaign_ID(order.getC_Campaign_ID());
    setC_Activity_ID(order.getC_Activity_ID());
    setUser1_ID(order.getUser1_ID());
    setUser2_ID(order.getUser2_ID());
    setC_CostCenter_ID(order.getC_CostCenter_ID());
    setC_Department_ID(order.getC_Department_ID());
}

2.3 Constructor from MInOut (Material Receipt)

An AP invoice can also be created directly from a material receipt. This is commonly used when the vendor invoice arrives after the goods have already been received:

/**
 * Create Invoice from Shipment/Receipt
 * @param ship shipment/receipt
 * @param invoiceDate date or null
 */
public MInvoice(MInOut ship, Timestamp invoiceDate) {
    this(ship.getCtx(), 0, ship.get_TrxName());
    setClientOrg(ship);
    setShipment(ship);    // set base settings
    //
    setC_DocTypeTarget_ID();
    if (invoiceDate != null)
        setDateInvoiced(invoiceDate);
    setDateAcct(getDateInvoiced());
    //
    if (getSalesRep_ID() == 0)
        setSalesRep_ID(ship.getSalesRep_ID());
}

2.4 setBPartner() — AP-Specific Behavior

A critical detail for AP invoices: the setBPartner() method selects the location based on the transaction type. For sales (IsSOTrx=true) it uses the BillTo location, but for purchasing (IsSOTrx=false) it uses the PayFrom location:

public void setBPartner(MBPartner bp) {
    if (bp == null) return;
    setC_BPartner_ID(bp.getC_BPartner_ID());

    // Payment Term: SO uses customer term, PO uses vendor term
    int ii = 0;
    if (isSOTrx())
        ii = bp.getC_PaymentTerm_ID();
    else
        ii = bp.getPO_PaymentTerm_ID();
    if (ii != 0)
        setC_PaymentTerm_ID(ii);

    // Price List: SO uses customer list, PO uses vendor list
    if (isSOTrx())
        ii = bp.getM_PriceList_ID();
    else
        ii = bp.getPO_PriceList_ID();
    if (ii != 0)
        setM_PriceList_ID(ii);

    // Set Locations - BillTo for SO, PayFrom for PO
    MBPartnerLocation[] locs = bp.getLocations(false);
    if (locs != null) {
        for (int i = 0; i < locs.length; i++) {
            if ((locs[i].isBillTo() && isSOTrx())
                || (locs[i].isPayFrom() && !isSOTrx()))
                setC_BPartner_Location_ID(
                    locs[i].getC_BPartner_Location_ID());
        }
        // fallback to first location
        if (getC_BPartner_Location_ID() == 0 && locs.length > 0)
            setC_BPartner_Location_ID(
                locs[0].getC_BPartner_Location_ID());
    }
    // ...
}

2.5 completeIt() — Matching Logic for AP Invoices

The most important part of the AP invoice lifecycle is what happens during completeIt(). The method iterates over each invoice line and creates matching records. There are two distinct matching paths:

Path 1: Invoice line has an M_InOutLine_ID (linked to a receipt line) — Creates an MMatchInv record directly:

// Inside MInvoice.completeIt() - for each invoice line:
if (!isSOTrx()
    && line.getM_InOutLine_ID() != 0
    && line.getM_Product_ID() != 0
    && !isReversal())
{
    MInOutLine receiptLine = new MInOutLine(getCtx(),
        line.getM_InOutLine_ID(), get_TrxName());
    MInOut receipt = receiptLine.getParent();

    if (receipt.isProcessed()) {
        BigDecimal matchQty = isCreditMemo()
            ? line.getQtyInvoiced().negate()
            : line.getQtyInvoiced();

        MMatchInv inv = new MMatchInv(line, getDateInvoiced(), matchQty);
        if (!inv.save(get_TrxName())) {
            m_processMsg = CLogger.retrieveErrorString(
                "Could not create Invoice Matching");
            return DocAction.STATUS_Invalid;
        }
        matchInv++;
        addDocsPostProcess(inv);
    }
}

Path 2: Invoice line has a C_OrderLine_ID (linked to a PO line) — Creates an MMatchPO record, which may in turn auto-create an MMatchInv:

// Inside MInvoice.completeIt() - for PO-linked lines with products:
else if (!isSOTrx()
    && line.getM_Product_ID() != 0
    && !isReversal())
{
    BigDecimal matchQty = isCreditMemo()
        ? line.getQtyInvoiced().negate()
        : line.getQtyInvoiced();
    MMatchPO po = MMatchPO.create(line, null,
        getDateInvoiced(), matchQty);
    if (po != null) {
        if (!po.save(get_TrxName())) {
            m_processMsg = "Could not create PO Matching";
            return DocAction.STATUS_Invalid;
        }
        matchPO++;
        if (!po.isPosted())
            addDocsPostProcess(po);
    }
}

2.6 Key MInvoice Methods Reference

Method Return Type Description
MInvoice(MOrder, int, Timestamp) Constructor Creates AP invoice from purchase order with target doc type and date
MInvoice(MInOut, Timestamp) Constructor Creates AP invoice from material receipt with date
setOrder(MOrder) void Copies order header fields (currency, payment terms, PO reference, etc.)
setShipment(MInOut) void Copies shipment header fields and resolves order linkages
setBPartner(MBPartner) void Sets partner defaults; uses PayFrom location for AP, BillTo for AR
setC_DocTypeTarget_ID(String) void Sets target doc type by DocBaseType; auto-sets IsSOTrx
setC_DocTypeTarget_ID() void Sets default target doc type based on IsSOTrx flag
prepareIt() String Validates period, lines, and doc type; returns STATUS_InProgress or STATUS_Invalid
completeIt() String Creates matching records (MMatchPO, MMatchInv), updates order line quantities
getLines(boolean) MInvoiceLine[] Returns invoice lines, optionally requerying from database
copyFrom(MInvoice, ...) MInvoice (static) Creates a new invoice by copying from an existing one
getGrandTotal(boolean) BigDecimal Returns grand total, optionally negated for credit memos
getNotFullyMatchedToReceipt(...) List<MatchingRecord> (static) Finds AP invoices not fully matched to receipts
getFullOrPartiallyMatchedToReceipt(...) List<MatchingRecord> (static) Finds AP invoices with full or partial receipt matches

3. MInvoiceLine Deep Dive

The MInvoiceLine class represents a single line on an invoice. For AP invoice processing, it serves as the bridge between purchase order lines, receipt lines, and the invoice itself. The class is located at:

org.adempiere.base/src/org/compiere/model/MInvoiceLine.java

3.1 setOrderLine() — Copying from a PO Line

The setOrderLine(MOrderLine oLine) method copies pricing and descriptive information from a purchase order line to the invoice line. This is the mechanism that carries the agreed PO price into the invoice for later variance comparison. Note that it does not set quantities — those must be set separately:

/**
 * Set values from Order Line. Does not set quantity!
 * @param oLine line
 */
public void setOrderLine(MOrderLine oLine) {
    setC_OrderLine_ID(oLine.getC_OrderLine_ID());
    setLine(oLine.getLine());
    setIsDescription(oLine.isDescription());
    setDescription(oLine.getDescription());
    //
    if (oLine.getM_Product_ID() == 0)
        setC_Charge_ID(oLine.getC_Charge_ID());
    //
    setM_Product_ID(oLine.getM_Product_ID());
    setM_AttributeSetInstance_ID(oLine.getM_AttributeSetInstance_ID());
    setS_ResourceAssignment_ID(oLine.getS_ResourceAssignment_ID());
    setC_UOM_ID(oLine.getC_UOM_ID());
    // Pricing copied from PO line
    setPriceEntered(oLine.getPriceEntered());
    setPriceActual(oLine.getPriceActual());
    setPriceLimit(oLine.getPriceLimit());
    setPriceList(oLine.getPriceList());
    //
    setC_Tax_ID(oLine.getC_Tax_ID());
    setLineNetAmt(oLine.getLineNetAmt());
    // Accounting dimensions
    setC_Project_ID(oLine.getC_Project_ID());
    setC_ProjectPhase_ID(oLine.getC_ProjectPhase_ID());
    setC_ProjectTask_ID(oLine.getC_ProjectTask_ID());
    setC_Activity_ID(oLine.getC_Activity_ID());
    setC_Campaign_ID(oLine.getC_Campaign_ID());
    setAD_OrgTrx_ID(oLine.getAD_OrgTrx_ID());
    setUser1_ID(oLine.getUser1_ID());
    setUser2_ID(oLine.getUser2_ID());
    setC_CostCenter_ID(oLine.getC_CostCenter_ID());
    setC_Department_ID(oLine.getC_Department_ID());
    //
    setRRAmt(oLine.getRRAmt());
    setRRStartDate(oLine.getRRStartDate());
}

3.2 setShipLine() — Copying from a Receipt Line

The setShipLine(MInOutLine sLine) method copies product, pricing (from the linked order line), and dimension information from a material receipt line. If the receipt line references a PO order line, the pricing comes from the PO; otherwise it falls back to the product's standard price list pricing:

/**
 * Set values from Shipment Line. Does not set quantity!
 * @param sLine ship line
 */
public void setShipLine(MInOutLine sLine) {
    setM_InOutLine_ID(sLine.getM_InOutLine_ID());
    setC_OrderLine_ID(sLine.getC_OrderLine_ID());
    setM_RMALine_ID(sLine.getM_RMALine_ID());
    //
    setLine(sLine.getLine());
    setIsDescription(sLine.isDescription());
    setDescription(sLine.getDescription());
    setM_Product_ID(sLine.getM_Product_ID());

    if (sLine.sameOrderLineUOM() || getProduct() == null)
        setC_UOM_ID(sLine.getC_UOM_ID());
    else
        setC_UOM_ID(getProduct().getC_UOM_ID());

    setM_AttributeSetInstance_ID(sLine.getM_AttributeSetInstance_ID());
    //
    int C_OrderLine_ID = sLine.getC_OrderLine_ID();
    if (C_OrderLine_ID != 0) {
        MOrderLine oLine = new MOrderLine(getCtx(),
            C_OrderLine_ID, get_TrxName());
        // Pricing from PO order line
        if (sLine.sameOrderLineUOM())
            setPriceEntered(oLine.getPriceEntered());
        else
            setPriceEntered(oLine.getPriceActual());
        setPriceActual(oLine.getPriceActual());
        setPriceLimit(oLine.getPriceLimit());
        setPriceList(oLine.getPriceList());
        setC_Tax_ID(oLine.getC_Tax_ID());
        setLineNetAmt(oLine.getLineNetAmt());
        setC_Project_ID(oLine.getC_Project_ID());
    } else {
        // No PO link - use standard pricing
        setPrice();
        setTax();
    }
    // Copy accounting dimensions from receipt line
    setC_Project_ID(sLine.getC_Project_ID());
    setC_ProjectPhase_ID(sLine.getC_ProjectPhase_ID());
    setC_ProjectTask_ID(sLine.getC_ProjectTask_ID());
    setC_Activity_ID(sLine.getC_Activity_ID());
    setC_Campaign_ID(sLine.getC_Campaign_ID());
    setAD_OrgTrx_ID(sLine.getAD_OrgTrx_ID());
    setUser1_ID(sLine.getUser1_ID());
    setUser2_ID(sLine.getUser2_ID());
    setC_CostCenter_ID(sLine.getC_CostCenter_ID());
    setC_Department_ID(sLine.getC_Department_ID());
}

3.3 getMatchedQty() — Querying Match Status

The getMatchedQty() method queries the M_MatchInv table to determine how much of an invoice line has already been matched to receipt lines:

/**
 * @return matched qty
 */
public BigDecimal getMatchedQty() {
    String sql = "SELECT COALESCE(SUM(" + MMatchInv.COLUMNNAME_Qty + "),0)"
        + " FROM " + MMatchInv.Table_Name
        + " WHERE " + MMatchInv.COLUMNNAME_C_InvoiceLine_ID + "=?"
        + " AND " + MMatchInv.COLUMNNAME_Processed + "=?";
    return DB.getSQLValueBDEx(get_TrxName(), sql,
        getC_InvoiceLine_ID(), true);
}

3.4 allocateLandedCosts() — Cost Distribution

The allocateLandedCosts() method is used to distribute freight, customs duties, and other landed costs across received product lines. It supports three distribution modes based on the MLandedCost configuration:

  • Entire Receipt: Distributes cost proportionally across all product lines of a receipt
  • Single Receipt Line: Allocates the entire cost to a specific receipt line
  • Single Product: Allocates cost to a specific product regardless of receipt
/**
 * Allocate Landed Costs
 * @return error message or ""
 */
public String allocateLandedCosts() {
    if (isProcessed())
        return "Processed";
    // Delete existing allocations
    StringBuilder sql = new StringBuilder(
        "DELETE FROM C_LandedCostAllocation WHERE C_InvoiceLine_ID=")
        .append(getC_InvoiceLine_ID());
    int no = DB.executeUpdate(sql.toString(), get_TrxName());

    MLandedCost[] lcs = MLandedCost.getLandedCosts(this);
    if (lcs.length == 0)
        return "";

    // For each receipt line, create an allocation record
    // proportional to the base (quantity, amount, weight, or volume)
    // ...
}

3.5 Static Lookup Methods

MInvoiceLine provides two static methods for finding invoice lines linked to receipt lines:

/**
 * Get Invoice Line referencing InOut Line (direct FK link)
 * @param sLine shipment line
 * @return (first) invoice line
 */
public static MInvoiceLine getOfInOutLine(MInOutLine sLine) {
    final String whereClause =
        "C_InvoiceLine.M_InOutLine_ID=? AND C_Invoice.Processed='Y'";
    // Uses JOIN to C_Invoice for Processed check
    // ...
}

/**
 * Get Invoice Line referencing InOut Line from M_MatchInv table
 * @param sLine shipment line
 * @return (first) invoice line
 */
public static MInvoiceLine getOfInOutLineFromMatchInv(MInOutLine sLine) {
    final String whereClause =
        "C_InvoiceLine_ID IN (SELECT C_InvoiceLine_ID "
        + "FROM M_MatchInv WHERE M_InOutLine_ID=?)";
    // ...
}

3.6 Key MInvoiceLine Fields Reference

Field/Column Type Description
C_InvoiceLine_ID int (PK) Primary key of the invoice line
C_Invoice_ID int (FK) Parent invoice header
C_OrderLine_ID int (FK) Link to purchase order line (for PO-based invoices)
M_InOutLine_ID int (FK) Link to material receipt line (for receipt-based invoices)
M_Product_ID int (FK) Product being invoiced
QtyInvoiced BigDecimal Quantity invoiced in product UOM
QtyEntered BigDecimal Quantity entered in entry UOM
PriceActual BigDecimal Actual unit price (not updateable after set)
PriceEntered BigDecimal Price entered by user (may differ from PriceActual due to UOM conversion)
PriceList BigDecimal List price for reference
PriceLimit BigDecimal Minimum price (for enforcement)
LineNetAmt BigDecimal Line total = PriceEntered * QtyEntered
M_AttributeSetInstance_ID int (FK) Attribute set instance (lot, serial number, etc.)

4. MMatchPO Deep Dive

The MMatchPO class is the central model for PO matching. Every record in M_MatchPO links a purchase order line to either a receipt line, an invoice line, or both. It is located at:

org.adempiere.base/src/org/compiere/model/MMatchPO.java

The class Javadoc summarizes its role:

/**
 * Match PO Model.
 * Created when processing Shipment or Order
 * - Updates Order (delivered, invoiced)
 * - Creates PPV acct
 */
public class MMatchPO extends X_M_MatchPO { ... }

4.1 Static Query Methods

MMatchPO provides several static methods for querying existing match records:

/**
 * Get PO Match with order line and invoice line
 * @param ctx context
 * @param C_OrderLine_ID order line
 * @param C_InvoiceLine_ID invoice line
 * @param trxName transaction
 * @return array of matches
 */
public static MMatchPO[] get(Properties ctx,
    int C_OrderLine_ID, int C_InvoiceLine_ID, String trxName)
{
    if (C_OrderLine_ID == 0 || C_InvoiceLine_ID == 0)
        return new MMatchPO[]{};
    String sql = "SELECT * FROM M_MatchPO "
        + "WHERE C_OrderLine_ID=? AND C_InvoiceLine_ID=?";
    // ...
}

/**
 * Get PO Match of Receipt Line
 * @param ctx context
 * @param M_InOutLine_ID receipt line
 * @param trxName transaction
 * @return array of matches
 */
public static MMatchPO[] get(Properties ctx,
    int M_InOutLine_ID, String trxName)
{
    String sql = "SELECT * FROM M_MatchPO WHERE M_InOutLine_ID=?";
    // ...
}

/**
 * Get PO Matches of receipt (entire document)
 */
public static MMatchPO[] getInOut(Properties ctx,
    int M_InOut_ID, String trxName)
{
    String sql = "SELECT * FROM M_MatchPO m"
        + " INNER JOIN M_InOutLine l"
        + " ON (m.M_InOutLine_ID=l.M_InOutLine_ID) "
        + "WHERE l.M_InOut_ID=?";
    // ...
}

/**
 * Get PO Matches of Invoice
 */
public static MMatchPO[] getInvoice(Properties ctx,
    int C_Invoice_ID, String trxName)
{
    String sql = "SELECT * FROM M_MatchPO mi"
        + " INNER JOIN C_InvoiceLine il"
        + " ON (mi.C_InvoiceLine_ID=il.C_InvoiceLine_ID) "
        + "WHERE il.C_Invoice_ID=?";
    // ...
}

/**
 * Get PO Matches for OrderLine
 */
public static MMatchPO[] getOrderLine(Properties ctx,
    int C_OrderLine_ID, String trxName)
{
    String sql = "SELECT * FROM M_MatchPO WHERE C_OrderLine_ID=?";
    // ...
}

4.2 Constructors

MMatchPO has two specialized constructors for creating match records from receipt lines and invoice lines respectively:

/**
 * Shipment Line Constructor
 * @param sLine shipment line
 * @param dateTrx optional date
 * @param qty matched quantity
 */
public MMatchPO(MInOutLine sLine, Timestamp dateTrx, BigDecimal qty) {
    this(sLine.getCtx(), 0, sLine.get_TrxName());
    setClientOrg(sLine);
    setM_InOutLine_ID(sLine.getM_InOutLine_ID());
    setC_OrderLine_ID(sLine.getC_OrderLine_ID());
    if (dateTrx != null)
        setDateTrx(dateTrx);
    setM_Product_ID(sLine.getM_Product_ID());
    setM_AttributeSetInstance_ID(sLine.getM_AttributeSetInstance_ID());
    setQty(qty);
    setProcessed(true);    // auto
}

/**
 * Invoice Line Constructor
 * @param iLine invoice line
 * @param dateTrx optional date
 * @param qty matched quantity
 */
public MMatchPO(MInvoiceLine iLine, Timestamp dateTrx, BigDecimal qty) {
    this(iLine.getCtx(), 0, iLine.get_TrxName());
    setClientOrg(iLine);
    setC_InvoiceLine_ID(iLine);
    if (iLine.getC_OrderLine_ID() != 0)
        setC_OrderLine_ID(iLine.getC_OrderLine_ID());
    if (dateTrx != null)
        setDateTrx(dateTrx);
    setM_Product_ID(iLine.getM_Product_ID());
    setM_AttributeSetInstance_ID(iLine.getM_AttributeSetInstance_ID());
    setQty(qty);
    setProcessed(true);    // auto
}

4.3 The create() Method — Main Entry Point

The create() method is the primary entry point for creating or updating MMatchPO records. It is called from MInvoice.completeIt() and MInOut.completeIt(). The method has a sophisticated algorithm:

/**
 * Update or Create Match PO record
 * @param iLine invoice line (may be null)
 * @param sLine receipt line (may be null)
 * @param dateTrx transaction date
 * @param qty qty to match
 * @return Match PO Record
 */
public static MMatchPO create(MInvoiceLine iLine,
    MInOutLine sLine, Timestamp dateTrx, BigDecimal qty)
{
    String trxName = null;
    Properties ctx = null;
    int C_OrderLine_ID = 0;
    // Determine C_OrderLine_ID from iLine or sLine
    if (iLine != null) {
        trxName = iLine.get_TrxName();
        ctx = iLine.getCtx();
        C_OrderLine_ID = iLine.getC_OrderLine_ID();
    }
    if (sLine != null) {
        trxName = sLine.get_TrxName();
        ctx = sLine.getCtx();
        C_OrderLine_ID = sLine.getC_OrderLine_ID();
    }

    if (C_OrderLine_ID > 0) {
        return create(ctx, iLine, sLine, C_OrderLine_ID,
            dateTrx, qty, trxName);
    } else {
        // No order line - try to find via existing MatchPO records
        if (sLine != null && iLine != null) {
            MMatchPO[] matchpos = MMatchPO.get(ctx,
                sLine.getM_InOutLine_ID(), trxName);
            for (MMatchPO matchpo : matchpos) {
                C_OrderLine_ID = matchpo.getC_OrderLine_ID();
                MOrderLine orderLine = new MOrderLine(ctx,
                    C_OrderLine_ID, trxName);
                BigDecimal toInvoice = orderLine.getQtyOrdered()
                    .subtract(orderLine.getQtyInvoiced());
                // ... match up to remaining quantity
            }
        }
        return null;
    }
}

4.4 Internal create() Logic — Finding and Updating Existing Matches

The internal create() method (with the full parameter list) implements the "update or create" pattern. It first looks for existing unmatched MMatchPO records through the MatchPOAutoMatch utility class:

protected static MMatchPO create(Properties ctx, MInvoiceLine iLine,
    MInOutLine sLine, int C_OrderLine_ID, Timestamp dateTrx,
    BigDecimal qty, String trxName)
{
    MMatchPO retValue = null;
    List<MMatchPO> matchPOList =
        MatchPOAutoMatch.getNotMatchedMatchPOList(ctx,
            C_OrderLine_ID, trxName);
    if (!matchPOList.isEmpty()) {
        for (MMatchPO mpo : matchPOList) {
            if (qty.compareTo(mpo.getQty()) >= 0) {
                // Update existing record with invoice or receipt info
                if (iLine != null)
                    mpo.setC_InvoiceLine_ID(iLine);
                if (sLine != null)
                    mpo.setM_InOutLine_ID(sLine.getM_InOutLine_ID());

                // Auto-create MMatchInv when both invoice and receipt
                // lines are now present
                if ((iLine != null || mpo.getC_InvoiceLine_ID() > 0)
                    && (sLine != null || mpo.getM_InOutLine_ID() > 0))
                {
                    int cnt = DB.getSQLValue(mpo.get_TrxName(),
                        "SELECT Count(*) FROM M_MatchInv "
                        + "WHERE M_InOutLine_ID=" + M_InOutLine_ID
                        + " AND C_InvoiceLine_ID=" + C_InvoiceLine_ID);
                    if (cnt <= 0) {
                        MMatchInv matchInv = createMatchInv(mpo,
                            C_InvoiceLine_ID, M_InOutLine_ID,
                            mpo.getQty(), dateTrx, trxName);
                        mpo.setMatchInvCreated(matchInv);
                    }
                }
                mpo.save();
                // ...
            }
        }
    }
    // If no existing record found, create new
    if (retValue == null) {
        // Create new MMatchPO from sLine or iLine
        // ...
    }
    return retValue;
}

4.5 createMatchInv() — Auto-Creating Receipt-Invoice Matches

When an MMatchPO record gains both a receipt line and an invoice line reference, the system automatically creates a corresponding MMatchInv record. This is done through the createMatchInv() method, which uses a database savepoint for safe rollback on failure:

/**
 * Create MatchInv record
 * @param mpo the MatchPO record
 * @param C_InvoiceLine_ID invoice line
 * @param M_InOutLine_ID receipt line
 * @param qty quantity
 * @param dateTrx transaction date
 * @param trxName transaction
 * @return Match Inv record
 */
protected static MMatchInv createMatchInv(MMatchPO mpo,
    int C_InvoiceLine_ID, int M_InOutLine_ID,
    BigDecimal qty, Timestamp dateTrx, String trxName)
{
    Savepoint savepoint = null;
    Trx trx = null;
    MMatchInv matchInv = null;
    try {
        trx = trxName != null ? Trx.get(trxName, false) : null;
        savepoint = trx != null
            ? trx.getConnection().setSavepoint() : null;

        matchInv = new MMatchInv(mpo.getCtx(), 0, mpo.get_TrxName());
        matchInv.setC_InvoiceLine_ID(C_InvoiceLine_ID);
        matchInv.setM_Product_ID(mpo.getM_Product_ID());
        matchInv.setM_InOutLine_ID(M_InOutLine_ID);
        matchInv.setAD_Client_ID(mpo.getAD_Client_ID());
        matchInv.setAD_Org_ID(mpo.getAD_Org_ID());
        matchInv.setM_AttributeSetInstance_ID(
            mpo.getM_AttributeSetInstance_ID());
        matchInv.setQty(qty);
        matchInv.setDateTrx(dateTrx);
        matchInv.setProcessed(true);

        if (!matchInv.save()) {
            if (savepoint != null) {
                trx.getConnection().rollback(savepoint);
                savepoint = null;
            }
            matchInv = null;
        }
    } catch (Exception e) {
        matchInv = null;
    } finally {
        if (savepoint != null) {
            try { trx.getConnection().releaseSavepoint(savepoint); }
            catch (Exception e) {}
        }
    }
    return matchInv;
}

4.6 getOrCreate() Pattern

The getOrCreate() method provides a convenient way to find an existing unposted MMatchPO record for a given order line and quantity, or create a new one if none exists. This is used during receipt processing:

/**
 * Get or create Match PO record for order line.
 * @param C_OrderLine_ID order line
 * @param qty quantity
 * @param sLine receipt line
 * @param trxName transaction
 * @return new or existing MMatchPO record
 */
public static MMatchPO getOrCreate(int C_OrderLine_ID,
    BigDecimal qty, MInOutLine sLine, String trxName)
{
    Query query = new Query(Env.getCtx(), MMatchPO.Table_Name,
        "C_OrderLine_ID=? AND Qty=? AND Posted IN (?,?) "
        + "AND M_InOutLine_ID IS NULL", trxName);
    MMatchPO matchPO = query.setParameters(
        C_OrderLine_ID, qty,
        Doc.STATUS_NotPosted, Doc.STATUS_Deferred).first();
    if (matchPO != null) {
        matchPO.setM_InOutLine_ID(sLine.getM_InOutLine_ID());
        return matchPO;
    } else {
        return new MMatchPO(sLine, null, qty);
    }
}

4.7 getInvoicePriceActual() — PPV Calculation Helper

The getInvoicePriceActual() method retrieves the actual invoice price and converts it to the order's currency. This is essential for calculating Purchase Price Variance:

/**
 * Get PriceActual from Invoice and convert to Order Currency.
 * @return Price Actual in Order Currency
 */
public BigDecimal getInvoicePriceActual() {
    MInvoiceLine iLine = getInvoiceLine();
    MInvoice invoice = iLine.getParent();
    MOrder order = getOrderLine().getParent();

    BigDecimal priceActual = iLine.getPriceActual();
    int invoiceCurrency_ID = invoice.getC_Currency_ID();
    int orderCurrency_ID = order.getC_Currency_ID();

    if (invoiceCurrency_ID != orderCurrency_ID) {
        priceActual = MConversionRate.convert(getCtx(),
            priceActual,
            invoiceCurrency_ID, orderCurrency_ID,
            invoice.getDateInvoiced(),
            invoice.getC_ConversionType_ID(),
            getAD_Client_ID(), getAD_Org_ID());

        if (priceActual == null)
            throw new AdempiereException(
                MConversionRateUtil.getErrorMessage(getCtx(),
                    "ErrorConvertingCurrencyToBaseCurrency",
                    invoiceCurrency_ID, orderCurrency_ID,
                    invoice.getC_ConversionType_ID(),
                    invoice.getDateInvoiced(), get_TrxName()));
    }
    return priceActual;
}

4.8 reverse() Method

The reverse() method creates a reversal record with negated quantity and handles the complex task of maintaining correct order line quantities and matching state:

/**
 * Reverse this MatchPO document.
 * @param reversalDate reversal date
 * @param reverseMatchingOnly true if MR is not reversed
 * @return true if reversed
 */
public boolean reverse(Timestamp reversalDate,
    boolean reverseMatchingOnly)
{
    if (this.isProcessed() && this.getReversal_ID() == 0) {
        MMatchPO reversal = new MMatchPO(getCtx(), 0, get_TrxName());
        reversal.setC_InvoiceLine_ID(getC_InvoiceLine_ID());
        reversal.setM_InOutLine_ID(getM_InOutLine_ID());
        if (getC_OrderLine_ID() != 0)
            reversal.setC_OrderLine_ID(getC_OrderLine_ID());
        reversal.setM_Product_ID(getM_Product_ID());
        reversal.setM_AttributeSetInstance_ID(
            getM_AttributeSetInstance_ID());
        reversal.setAD_Org_ID(this.getAD_Org_ID());
        reversal.setDescription("(->" + this.getDocumentNo() + ")");
        reversal.setQty(this.getQty().negate());
        reversal.setDateAcct(reversalDate);
        reversal.setDateTrx(reversalDate);
        reversal.setPosted(false);
        reversal.setProcessed(true);
        reversal.setReversal_ID(getM_MatchPO_ID());
        reversal.saveEx();

        this.setDescription("(" + reversal.getDocumentNo() + "<-)");
        this.setReversal_ID(reversal.getM_MatchPO_ID());
        this.saveEx();

        // If reverseMatchingOnly, restore order line reserved qty
        // and auto-create new MatchPO for invoice line if needed
        // ...
        return true;
    }
    return false;
}

4.9 beforeSave() — PPV and Tolerance Calculation

The beforeSave() method in MMatchPO performs the critical Purchase Price Variance calculation when both a PO line and an invoice line are present:

// In MMatchPO.beforeSave():
// Set PriceMatchDifference to difference between PO and Invoice price
if (getC_OrderLine_ID() != 0
    && getC_InvoiceLine_ID() != 0
    && (newRecord
        || is_ValueChanged("C_OrderLine_ID")
        || is_ValueChanged("C_InvoiceLine_ID")))
{
    BigDecimal poPrice = getOrderLine().getPriceActual();
    BigDecimal invPrice = getInvoicePriceActual();
    BigDecimal difference = poPrice.subtract(invPrice);
    if (difference.signum() != 0) {
        difference = difference.multiply(getQty());
        setPriceMatchDifference(difference);

        // Validate against PriceMatchTolerance
        MBPGroup group = MBPGroup.getOfBPartner(getCtx(),
            getOrderLine().getC_BPartner_ID());
        BigDecimal mt = group.getPriceMatchTolerance();
        if (mt != null && mt.signum() != 0) {
            BigDecimal poAmt = poPrice.multiply(getQty());
            BigDecimal maxTolerance = poAmt.multiply(mt)
                .abs()
                .divide(Env.ONEHUNDRED, 2, RoundingMode.HALF_UP);
            difference = difference.abs();
            boolean ok = difference.compareTo(maxTolerance) <= 0;
            setIsApproved(ok);
        }
    } else {
        setPriceMatchDifference(difference);
        setIsApproved(true);
    }
}

4.10 afterSave() — Quantity Validation and Order Updates

The afterSave() method performs critical validations and updates the parent order line:

// In MMatchPO.afterSave():
// 1. Validate matched qty against receipt movement qty
if (getM_InOutLine_ID() > 0) {
    MInOutLine line = new MInOutLine(getCtx(),
        getM_InOutLine_ID(), get_TrxName());
    BigDecimal matchedQty = DB.getSQLValueBD(get_TrxName(),
        "SELECT Coalesce(SUM(Qty),0) FROM M_MatchPO "
        + "WHERE M_InOutLine_ID=?", getM_InOutLine_ID());
    if (matchedQty.compareTo(line.getMovementQty()) > 0) {
        throw new IllegalStateException(
            "Total matched qty > movement qty.");
    }
}
// 2. Validate matched qty against invoice qty
if (getC_InvoiceLine_ID() > 0) {
    MInvoiceLine line = new MInvoiceLine(getCtx(),
        getC_InvoiceLine_ID(), get_TrxName());
    BigDecimal matchedQty = DB.getSQLValueBD(get_TrxName(),
        "SELECT Coalesce(SUM(Qty),0) FROM M_MatchPO "
        + "WHERE C_InvoiceLine_ID=? AND Reversal_ID IS NULL",
        getC_InvoiceLine_ID());
    if (matchedQty.compareTo(line.getQtyInvoiced()) > 0) {
        throw new IllegalStateException(
            "Total matched qty > invoiced qty.");
    }
}
// 3. Update order line QtyDelivered and QtyInvoiced
if (getC_OrderLine_ID() != 0) {
    MOrderLine orderLine = getOrderLine();
    if (m_isInOutLineChange)
        orderLine.setQtyDelivered(
            orderLine.getQtyDelivered().add(getQty()));
    if (m_isInvoiceLineChange)
        orderLine.setQtyInvoiced(
            orderLine.getQtyInvoiced().add(getQty()));
    return orderLine.save();
}

4.11 Key MMatchPO Methods Reference

Method Return Type Description
get(ctx, C_OrderLine_ID, C_InvoiceLine_ID, trx) MMatchPO[] (static) Get matches by order line and invoice line
get(ctx, M_InOutLine_ID, trx) MMatchPO[] (static) Get matches by receipt line
getInOut(ctx, M_InOut_ID, trx) MMatchPO[] (static) Get all matches for a receipt document
getInvoice(ctx, C_Invoice_ID, trx) MMatchPO[] (static) Get all matches for an invoice document
getOrderLine(ctx, C_OrderLine_ID, trx) MMatchPO[] (static) Get all matches for an order line
create(iLine, sLine, dateTrx, qty) MMatchPO (static) Main entry: update or create MatchPO record
createMatchInv(mpo, ...) MMatchInv (static, protected) Auto-create MatchInv when both receipt and invoice present
getOrCreate(C_OrderLine_ID, qty, sLine, trx) MMatchPO (static) Find existing unposted match or create new
getInvoicePriceActual() BigDecimal Invoice price converted to order currency
reverse(reversalDate) boolean Reverse this match (negated qty)
reverse(reversalDate, reverseMatchingOnly) boolean Reverse with optional matching-only mode
getInvoiceLine() MInvoiceLine Get cached or loaded invoice line
getOrderLine() MOrderLine Get cached or loaded order line
getMatchInvCreated() MMatchInv Get auto-created MatchInv for immediate posting (one-time read)

5. MMatchInv Deep Dive

The MMatchInv class handles the Receipt-to-Invoice matching. Each M_MatchInv record links a specific receipt line (M_InOutLine_ID) to a specific invoice line (C_InvoiceLine_ID). The class is located at:

org.adempiere.base/src/org/compiere/model/MMatchInv.java

The class Javadoc summarizes its accounting role:

/**
 * Match Invoice (Receipt<>Invoice) Model.
 * Accounting:
 * - Not Invoiced Receipts (relief)
 * - IPV (Invoice Price Variance)
 */
public class MMatchInv extends X_M_MatchInv { ... }

5.1 Static Query Methods

/**
 * Get InOut-Invoice Matches by both IDs
 * @param ctx context
 * @param M_InOutLine_ID shipment line
 * @param C_InvoiceLine_ID invoice line
 * @param trxName transaction
 * @return array of matches
 */
public static MMatchInv[] get(Properties ctx,
    int M_InOutLine_ID, int C_InvoiceLine_ID, String trxName)
{
    if (M_InOutLine_ID <= 0 || C_InvoiceLine_ID <= 0)
        return new MMatchInv[]{};
    final String whereClause =
        "M_InOutLine_ID=? AND C_InvoiceLine_ID=?";
    List<MMatchInv> list = new Query(ctx,
        I_M_MatchInv.Table_Name, whereClause, trxName)
        .setParameters(M_InOutLine_ID, C_InvoiceLine_ID)
        .list();
    return list.toArray(new MMatchInv[list.size()]);
}

/**
 * Get InOut Matches for an InvoiceLine
 */
public static MMatchInv[] getInvoiceLine(Properties ctx,
    int C_InvoiceLine_ID, String trxName)
{
    if (C_InvoiceLine_ID <= 0)
        return new MMatchInv[]{};
    String whereClause = "C_InvoiceLine_ID=?";
    List<MMatchInv> list = new Query(ctx,
        I_M_MatchInv.Table_Name, whereClause, trxName)
        .setParameters(C_InvoiceLine_ID)
        .list();
    return list.toArray(new MMatchInv[list.size()]);
}

/**
 * Get Invoice Matches for an InOutLine
 */
public static MMatchInv[] getInOutLine(Properties ctx,
    int M_InOutLine_ID, String trxName)
{
    if (M_InOutLine_ID <= 0)
        return new MMatchInv[]{};
    final String whereClause =
        MMatchInv.COLUMNNAME_M_InOutLine_ID + "=?";
    List<MMatchInv> list = new Query(ctx,
        I_M_MatchInv.Table_Name, whereClause, trxName)
        .setParameters(M_InOutLine_ID)
        .list();
    return list.toArray(new MMatchInv[list.size()]);
}

/**
 * Get InOut Matches for an Invoice
 */
public static MMatchInv[] getInvoice(Properties ctx,
    int C_Invoice_ID, String trxName)
{
    if (C_Invoice_ID == 0)
        return new MMatchInv[]{};
    final String whereClause =
        " EXISTS (SELECT 1 FROM C_InvoiceLine il"
        + " WHERE M_MatchInv.C_InvoiceLine_ID"
        + "=il.C_InvoiceLine_ID AND il.C_Invoice_ID=?)";
    List<MMatchInv> list = new Query(ctx,
        I_M_MatchInv.Table_Name, whereClause, trxName)
        .setParameters(C_Invoice_ID)
        .setOrderBy(COLUMNNAME_ProcessedOn)
        .list();
    return list.toArray(new MMatchInv[list.size()]);
}

/**
 * Get InOut Matches for Invoice filtered by DateAcct
 */
public static MMatchInv[] getInvoiceByDateAcct(Properties ctx,
    int C_Invoice_ID, Timestamp DateAcct, String trxName)
{
    // Uses raw SQL with JOIN to reversal for date ordering
    // Filters by DateAcct >= parameter
    // Orders by DateAcct, reversal grouping, M_MatchInv_ID
    // ...
}

5.2 Invoice Line Constructor

The constructor from an MInvoiceLine automatically sets the receipt line reference and marks the record as processed:

/**
 * Invoice Line Constructor
 * @param iLine invoice line
 * @param dateTrx optional date
 * @param qty matched quantity
 */
public MMatchInv(MInvoiceLine iLine, Timestamp dateTrx, BigDecimal qty) {
    this(iLine.getCtx(), 0, iLine.get_TrxName());
    setClientOrg(iLine);
    setC_InvoiceLine_ID(iLine.getC_InvoiceLine_ID());
    setM_InOutLine_ID(iLine.getM_InOutLine_ID());
    if (dateTrx != null)
        setDateTrx(dateTrx);
    setM_Product_ID(iLine.getM_Product_ID());
    setM_AttributeSetInstance_ID(
        iLine.getM_AttributeSetInstance_ID());
    setQty(qty);
    setProcessed(true);    // auto
}

5.3 beforeSave() — Date Accounting Logic

The beforeSave() method sets the accounting date to the later of the invoice date or receipt date. This ensures the matching entry is recorded in the correct accounting period:

@Override
protected boolean beforeSave(boolean newRecord) {
    if (getDateTrx() == null)
        setDateTrx(new Timestamp(System.currentTimeMillis()));
    if (getDateAcct() == null) {
        Timestamp ts = getNewerDateAcct();
        if (ts == null)
            ts = getDateTrx();
        setDateAcct(ts);
    }
    // Set ASI from receipt line if not set
    if (getM_AttributeSetInstance_ID() == 0
        && getM_InOutLine_ID() != 0)
    {
        MInOutLine iol = new MInOutLine(getCtx(),
            getM_InOutLine_ID(), get_TrxName());
        setM_AttributeSetInstance_ID(
            iol.getM_AttributeSetInstance_ID());
    }
    return true;
}

/**
 * Get the newer DateAcct between invoice and shipment
 */
public Timestamp getNewerDateAcct() {
    // Query invoice DateAcct via C_InvoiceLine JOIN C_Invoice
    Timestamp invoiceDate = ...;
    // Query receipt DateAcct via M_InOutLine JOIN M_InOut
    Timestamp shipDate = ...;

    if (invoiceDate == null) return shipDate;
    if (shipDate == null) return invoiceDate;
    if (invoiceDate.after(shipDate))
        return invoiceDate;
    return shipDate;
}

5.4 afterSave() — Quantity Validation

After saving, MMatchInv validates that the total matched quantity does not exceed the receipt's movement quantity or the invoice line's invoiced quantity:

@Override
protected boolean afterSave(boolean newRecord, boolean success) {
    if (!success) return false;

    // Validate: total matched qty <= receipt movement qty
    if (getM_InOutLine_ID() > 0) {
        MInOutLine line = new MInOutLine(getCtx(),
            getM_InOutLine_ID(), get_TrxName());
        BigDecimal matchedQty = DB.getSQLValueBD(get_TrxName(),
            "SELECT Coalesce(SUM(Qty),0) FROM M_MatchInv "
            + "WHERE M_InOutLine_ID=?", getM_InOutLine_ID());
        BigDecimal movementQty = line.getMovementQty();
        // Handle negative movement qty (returns)
        if (movementQty.signum() < 0) {
            movementQty = movementQty.negate();
            matchedQty = matchedQty.negate();
        }
        if (matchedQty != null
            && matchedQty.compareTo(movementQty) > 0)
        {
            throw new IllegalStateException(
                "Total matched qty > movement qty.");
        }
    }
    // Validate: total matched qty <= invoice line qty
    if (getC_InvoiceLine_ID() > 0) {
        MInvoiceLine line = new MInvoiceLine(getCtx(),
            getC_InvoiceLine_ID(), get_TrxName());
        BigDecimal matchedQty = DB.getSQLValueBD(get_TrxName(),
            "SELECT Coalesce(SUM(Qty),0) FROM M_MatchInv "
            + "WHERE C_InvoiceLine_ID=?",
            getC_InvoiceLine_ID());
        BigDecimal qtyInvoiced = line.getQtyInvoiced();
        if (matchedQty != null
            && matchedQty.compareTo(qtyInvoiced) > 0)
        {
            throw new IllegalStateException(
                "Total matched qty > invoiced qty.");
        }
    }
    return true;
}

5.5 reverse() Method

/**
 * Reverse this MatchInv document.
 * @param reversalDate reversal date
 * @return true if reversed
 */
public boolean reverse(Timestamp reversalDate) {
    if (this.isProcessed() && this.getReversal_ID() == 0) {
        MMatchInv reversal = new MMatchInv(getCtx(),
            0, get_TrxName());
        PO.copyValues(this, reversal);
        reversal.setAD_Org_ID(this.getAD_Org_ID());
        reversal.setDescription(
            "(->" + this.getDocumentNo() + ")");
        reversal.setQty(this.getQty().negate());
        reversal.setDateAcct(reversalDate);
        reversal.setDateTrx(reversalDate);
        reversal.set_ValueNoCheck("DocumentNo", null);
        reversal.setPosted(false);
        reversal.setReversal_ID(getM_MatchInv_ID());
        reversal.saveEx();

        this.setDescription(
            "(" + reversal.getDocumentNo() + "<-)");
        this.setReversal_ID(reversal.getM_MatchInv_ID());
        this.saveEx();
        return true;
    }
    return false;
}

5.6 beforeDelete() — Period and Posting Check

@Override
protected boolean beforeDelete() {
    if (isPosted()) {
        MPeriod.testPeriodOpen(getCtx(), getDateTrx(),
            MDocType.DOCBASETYPE_MatchInvoice, getAD_Org_ID());
        setPosted(false);
        MFactAcct.deleteEx(Table_ID, get_ID(), get_TrxName());
    }
    return true;
}

5.7 Key MMatchInv Methods Reference

Method Return Type Description
get(ctx, M_InOutLine_ID, C_InvoiceLine_ID, trx) MMatchInv[] (static) Get matches by both receipt and invoice line
getInvoiceLine(ctx, C_InvoiceLine_ID, trx) MMatchInv[] (static) Get all matches for an invoice line
getInOutLine(ctx, M_InOutLine_ID, trx) MMatchInv[] (static) Get all matches for a receipt line
getInvoice(ctx, C_Invoice_ID, trx) MMatchInv[] (static) Get all matches for an invoice document
getInOut(ctx, M_InOut_ID, trx) MMatchInv[] (static) Get all matches for a receipt document
getInvoiceByDateAcct(ctx, C_Invoice_ID, DateAcct, trx) MMatchInv[] (static) Get matches for invoice filtered by accounting date
MMatchInv(MInvoiceLine, Timestamp, BigDecimal) Constructor Create from invoice line with date and qty
getNewerDateAcct() Timestamp Returns the later DateAcct between invoice and receipt
reverse(Timestamp) boolean Reverse this match (negated qty, linked via Reversal_ID)
isReversal() boolean True if this record was created to reverse another
deleteMatchInvCostDetail() String (protected) Delete MCostDetail records for all account schemas

6. Document Flow: End-to-End Matching

Understanding the sequence in which matching records are created is essential for debugging and certification. There are two primary flows depending on which document arrives first after the PO.

6.1 Standard Flow: PO → Receipt → Invoice

This is the most common procurement flow:

Step Action Records Created Accounting Effect
1 Purchase Order completed No matching records yet Commitment accounting (if enabled)
2 Material Receipt completed M_MatchPO (C_OrderLine_ID + M_InOutLine_ID, no C_InvoiceLine_ID) Dr: Inventory, Cr: Not Invoiced Receipts (NIR)
3 Vendor Invoice completed M_MatchPO updated (adds C_InvoiceLine_ID) + M_MatchInv auto-created Dr: NIR relief, Cr: Accounts Payable; PPV if price differs

In code, when the receipt is completed (MInOut.completeIt()), for each receipt line with a PO reference:

// During receipt completion (simplified):
MMatchPO po = MMatchPO.create(null, sLine, dateTrx, qty);
// Creates MMatchPO with M_InOutLine_ID and C_OrderLine_ID
// C_InvoiceLine_ID is 0 at this point

Later, when the invoice is completed (MInvoice.completeIt()):

// During invoice completion:
MMatchPO po = MMatchPO.create(line, null, getDateInvoiced(), matchQty);
// The create() method finds the existing MMatchPO (receipt match)
// Updates it with C_InvoiceLine_ID
// Auto-creates MMatchInv linking receipt line to invoice line

6.2 Alternative Flow: PO → Invoice → Receipt

Sometimes the vendor invoice arrives before the goods. In this case:

Step Action Records Created Accounting Effect
1 Purchase Order completed No matching records Commitment accounting (if enabled)
2 Vendor Invoice completed M_MatchPO (C_OrderLine_ID + C_InvoiceLine_ID, no M_InOutLine_ID) Dr: Expense/Asset, Cr: Accounts Payable (no NIR yet)
3 Material Receipt completed M_MatchPO updated (adds M_InOutLine_ID) + M_MatchInv auto-created Dr: Inventory, Cr: NIR; then NIR relief via MMatchInv

6.3 Matching State Transitions

An MMatchPO record progresses through states as documents are completed:

State 1: Receipt-Only Match
  C_OrderLine_ID = set
  M_InOutLine_ID = set
  C_InvoiceLine_ID = 0
  (Created when receipt is completed before invoice)

State 2: Invoice-Only Match
  C_OrderLine_ID = set
  M_InOutLine_ID = 0
  C_InvoiceLine_ID = set
  (Created when invoice is completed before receipt)

State 3: Fully Matched
  C_OrderLine_ID = set
  M_InOutLine_ID = set
  C_InvoiceLine_ID = set
  (Updated when the missing document arrives)
  + M_MatchInv auto-created for Receipt-Invoice link

6.4 Period Checks During Matching

Both MMatchPO and MMatchInv enforce period openness checks before deleting posted records. This uses their respective DocBaseType constants:

// MMatchPO.beforeDelete():
MPeriod.testPeriodOpen(getCtx(), getDateTrx(),
    MDocType.DOCBASETYPE_MatchPO, getAD_Org_ID());

// MMatchInv.beforeDelete():
MPeriod.testPeriodOpen(getCtx(), getDateTrx(),
    MDocType.DOCBASETYPE_MatchInvoice, getAD_Org_ID());

This means that the accounting periods for both "MXP" (Match PO) and "MXI" (Match Invoice) must be open in order to delete or reverse matching records.


7. Price Variance Accounting

One of the most important outputs of three-way matching is the detection and accounting of price variances. iDempiere handles two types of variances through the matching process.

7.1 Not Invoiced Receipts (NIR)

When goods are received but not yet invoiced, the system creates a "Not Invoiced Receipts" liability entry. This is an interim account that represents goods the company has received but has not yet been billed for.

The accounting entries at receipt time (when MMatchPO is created with receipt line only):

Account Debit Credit
Inventory (Product Asset) PO Price * Qty
Not Invoiced Receipts PO Price * Qty

When the invoice arrives and MMatchInv is created, the NIR account is relieved:

Account Debit Credit
Not Invoiced Receipts PO Price * Qty
Inventory Clearing / Product Expense Invoice Price * Qty
Invoice Price Variance (IPV) Difference (if any)

7.2 Purchase Price Variance (PPV)

PPV is calculated in MMatchPO.beforeSave() when both a PO line and an invoice line are present. The variance is the difference between the PO price and the actual invoice price:

PPV = (PO Price - Invoice Price) * Matched Quantity

Key points about PPV calculation:

  • Currency conversion: The getInvoicePriceActual() method converts the invoice price to the order currency before comparison
  • The result is stored in: M_MatchPO.PriceMatchDifference
  • Positive PPV: PO price > Invoice price (favorable variance — you paid less than expected)
  • Negative PPV: PO price < Invoice price (unfavorable variance — you paid more than expected)

7.3 Price Match Tolerance

iDempiere supports a tolerance threshold for price variances, configured at the Business Partner Group level via the PriceMatchTolerance field. The tolerance is a percentage of the PO amount:

// From MMatchPO.beforeSave():
MBPGroup group = MBPGroup.getOfBPartner(getCtx(),
    getOrderLine().getC_BPartner_ID());
BigDecimal mt = group.getPriceMatchTolerance();
if (mt != null && mt.signum() != 0) {
    BigDecimal poAmt = poPrice.multiply(getQty());
    BigDecimal maxTolerance = poAmt.multiply(mt)
        .abs()
        .divide(Env.ONEHUNDRED, 2, RoundingMode.HALF_UP);
    difference = difference.abs();
    boolean ok = difference.compareTo(maxTolerance) <= 0;
    setIsApproved(ok);
}

If the absolute price difference exceeds the tolerance percentage, the MMatchPO record is marked as IsApproved = false, which can be used to flag invoices requiring managerial review before payment.

7.4 Accounting Entries Summary

Document DocBaseType Accounting Entries
M_MatchPO (Receipt only) MXP Dr: Inventory, Cr: Not Invoiced Receipts
M_MatchPO (Invoice + Receipt) MXP Dr/Cr: Purchase Price Variance (if any)
M_MatchInv MXI Dr: Not Invoiced Receipts (relief), Cr: Inventory Clearing; Dr/Cr: Invoice Price Variance
C_Invoice (AP) API Dr: Expense/Inventory Clearing, Cr: Accounts Payable

8. Complete Code Examples

8.1 Creating an AP Invoice from a Purchase Order

/**
 * Create and complete an AP Invoice from a Purchase Order.
 * Assumes the order is already completed.
 */
public MInvoice createAPInvoiceFromPO(MOrder order) {
    // Create invoice header from order
    MInvoice invoice = new MInvoice(order, 0, null);
    // IsSOTrx is automatically set to false from the PO
    // DocTypeTarget is set from the PO's doc type mapping
    invoice.setDateInvoiced(new Timestamp(System.currentTimeMillis()));
    invoice.setDateAcct(invoice.getDateInvoiced());
    invoice.saveEx();

    // Create invoice lines from order lines
    MOrderLine[] oLines = order.getLines(true, null);
    for (MOrderLine oLine : oLines) {
        MInvoiceLine iLine = new MInvoiceLine(invoice);
        iLine.setOrderLine(oLine);    // copies pricing from PO
        iLine.setQtyEntered(oLine.getQtyEntered());
        iLine.setQtyInvoiced(oLine.getQtyOrdered());
        iLine.saveEx();
    }

    // Complete the invoice
    // This triggers MMatchPO.create() for each line with a product
    if (!invoice.processIt(DocAction.ACTION_Complete)) {
        throw new AdempiereException(
            "Cannot complete invoice: " + invoice.getProcessMsg());
    }
    invoice.saveEx();
    return invoice;
}

8.2 Creating an AP Invoice from a Material Receipt

/**
 * Create and complete an AP Invoice from a Material Receipt.
 * Creates MMatchInv records automatically during completion.
 */
public MInvoice createAPInvoiceFromReceipt(MInOut receipt) {
    // Create invoice header from receipt
    MInvoice invoice = new MInvoice(receipt, null);
    invoice.setDateInvoiced(new Timestamp(System.currentTimeMillis()));
    invoice.setDateAcct(invoice.getDateInvoiced());
    invoice.saveEx();

    // Create invoice lines from receipt lines
    MInOutLine[] sLines = receipt.getLines(false);
    for (MInOutLine sLine : sLines) {
        if (sLine.isDescription() || sLine.getM_Product_ID() == 0)
            continue;

        MInvoiceLine iLine = new MInvoiceLine(invoice);
        iLine.setShipLine(sLine);    // copies pricing from PO via receipt
        iLine.setQtyEntered(sLine.getMovementQty());
        iLine.setQtyInvoiced(sLine.getMovementQty());
        iLine.saveEx();
    }

    // Complete the invoice
    // For lines with M_InOutLine_ID: creates MMatchInv directly
    // For lines with C_OrderLine_ID: creates MMatchPO which may
    //   auto-create MMatchInv
    if (!invoice.processIt(DocAction.ACTION_Complete)) {
        throw new AdempiereException(
            "Cannot complete invoice: " + invoice.getProcessMsg());
    }
    invoice.saveEx();
    return invoice;
}

8.3 Querying Match Records

/**
 * Query all matching records for a given purchase order line.
 */
public void inspectMatchingForOrderLine(int C_OrderLine_ID) {
    Properties ctx = Env.getCtx();
    String trxName = null;

    // Get all MMatchPO records for this order line
    MMatchPO[] matchPOs = MMatchPO.getOrderLine(ctx,
        C_OrderLine_ID, trxName);

    System.out.println("=== MMatchPO Records ===");
    for (MMatchPO mpo : matchPOs) {
        System.out.println("  M_MatchPO_ID=" + mpo.getM_MatchPO_ID()
            + " Qty=" + mpo.getQty()
            + " M_InOutLine_ID=" + mpo.getM_InOutLine_ID()
            + " C_InvoiceLine_ID=" + mpo.getC_InvoiceLine_ID()
            + " PriceMatchDiff=" + mpo.getPriceMatchDifference()
            + " IsApproved=" + mpo.isApproved()
            + " Reversal_ID=" + mpo.getReversal_ID());

        // For each match with an invoice, check MMatchInv
        if (mpo.getC_InvoiceLine_ID() > 0
            && mpo.getM_InOutLine_ID() > 0)
        {
            MMatchInv[] matchInvs = MMatchInv.get(ctx,
                mpo.getM_InOutLine_ID(),
                mpo.getC_InvoiceLine_ID(), trxName);

            System.out.println("  === MMatchInv Records ===");
            for (MMatchInv mi : matchInvs) {
                System.out.println("    M_MatchInv_ID="
                    + mi.getM_MatchInv_ID()
                    + " Qty=" + mi.getQty()
                    + " DateAcct=" + mi.getDateAcct());
            }
        }
    }
}

8.4 Handling Partial Matches

/**
 * Example: PO for 100 units, Receipt for 60 units, Invoice for 60.
 * Shows how partial matching works.
 */
public void partialMatchExample(Properties ctx, String trxName) {
    // After Receipt of 60 units is completed:
    // M_MatchPO record 1: C_OrderLine_ID=X, M_InOutLine_ID=Y,
    //   C_InvoiceLine_ID=0, Qty=60

    // After Invoice for 60 units is completed:
    // MMatchPO.create(invoiceLine, null, dateInvoiced, 60)
    //   -> Finds existing MMatchPO (record 1)
    //   -> Updates C_InvoiceLine_ID
    //   -> Auto-creates MMatchInv for receipt-invoice link

    // Remaining 40 units on the PO are still unmatched
    // The order line shows:
    //   QtyOrdered=100, QtyDelivered=60, QtyInvoiced=60

    // When 2nd receipt of 40 arrives:
    // M_MatchPO record 2: C_OrderLine_ID=X, M_InOutLine_ID=Z,
    //   C_InvoiceLine_ID=0, Qty=40

    // When 2nd invoice of 40 arrives:
    // M_MatchPO record 2 updated with C_InvoiceLine_ID
    // + M_MatchInv auto-created
    // Order line: QtyOrdered=100, QtyDelivered=100, QtyInvoiced=100
}

8.5 Verifying Three-Way Matching Completeness

/**
 * Check if a purchase order line is fully three-way matched.
 * @param C_OrderLine_ID the order line to check
 * @return true if fully matched
 */
public boolean isFullyThreeWayMatched(int C_OrderLine_ID) {
    Properties ctx = Env.getCtx();
    String trxName = null;

    MOrderLine oLine = new MOrderLine(ctx,
        C_OrderLine_ID, trxName);
    BigDecimal qtyOrdered = oLine.getQtyOrdered();

    // Check delivered qty
    BigDecimal qtyDelivered = oLine.getQtyDelivered();
    if (qtyDelivered.compareTo(qtyOrdered) < 0) {
        System.out.println("Not fully received: "
            + qtyDelivered + " of " + qtyOrdered);
        return false;
    }

    // Check invoiced qty
    BigDecimal qtyInvoiced = oLine.getQtyInvoiced();
    if (qtyInvoiced.compareTo(qtyOrdered) < 0) {
        System.out.println("Not fully invoiced: "
            + qtyInvoiced + " of " + qtyOrdered);
        return false;
    }

    // Verify all MMatchPO records have both receipt and invoice
    MMatchPO[] matchPOs = MMatchPO.getOrderLine(ctx,
        C_OrderLine_ID, trxName);
    for (MMatchPO mpo : matchPOs) {
        if (mpo.getReversal_ID() > 0) continue; // skip reversals

        if (mpo.getM_InOutLine_ID() == 0) {
            System.out.println("MMatchPO " + mpo.getM_MatchPO_ID()
                + " missing receipt line");
            return false;
        }
        if (mpo.getC_InvoiceLine_ID() == 0) {
            System.out.println("MMatchPO " + mpo.getM_MatchPO_ID()
                + " missing invoice line");
            return false;
        }

        // Verify corresponding MMatchInv exists
        MMatchInv[] matchInvs = MMatchInv.get(ctx,
            mpo.getM_InOutLine_ID(),
            mpo.getC_InvoiceLine_ID(), trxName);
        if (matchInvs.length == 0) {
            System.out.println("Missing MMatchInv for "
                + "M_InOutLine_ID=" + mpo.getM_InOutLine_ID()
                + ", C_InvoiceLine_ID="
                + mpo.getC_InvoiceLine_ID());
            return false;
        }
    }

    System.out.println("Order line " + C_OrderLine_ID
        + " is fully three-way matched.");
    return true;
}

8.6 Finding Unmatched AP Invoices

/**
 * Find all AP invoices that are not fully matched to receipts.
 * Uses MInvoice's built-in static method.
 */
public void findUnmatchedInvoices() {
    String trxName = null;

    // Parameters: C_BPartner_ID, M_Product_ID, M_InOutLine_ID,
    //             dateFrom, dateTo, trxName
    List<MInvoice.MatchingRecord> unmatched =
        MInvoice.getNotFullyMatchedToReceipt(
            0,      // all business partners
            0,      // all products
            0,      // all receipt lines
            null,   // no date filter (from)
            null,   // no date filter (to)
            trxName);

    for (MInvoice.MatchingRecord rec : unmatched) {
        System.out.println("Invoice " + rec.documentNo()
            + " Line " + rec.line()
            + " Product: " + rec.productName()
            + " QtyInvoiced: " + rec.qtyInvoiced()
            + " MatchedQty: " + rec.matchedQty()
            + " Unmatched: "
            + rec.qtyInvoiced().subtract(rec.matchedQty()));
    }
}

9. Common Pitfalls and Best Practices

9.1 Matching Tolerance Issues

  • Pitfall: The PriceMatchTolerance is set at the Business Partner Group level, not the individual partner level. If you need different tolerances for different vendors, you must use separate BP Groups.
  • Pitfall: A tolerance of zero means no tolerance check is performed (the code checks mt.signum() != 0). This is different from "zero tolerance" (which would reject any variance). To enforce strict zero tolerance, set a very small tolerance value like 0.01%.
  • Best Practice: Review unapproved MMatchPO records (IsApproved = false) regularly to ensure price variances are investigated and resolved before payment.

9.2 Partial Receipt and Invoice Scenarios

  • Pitfall: When a receipt has a different quantity than the invoice (e.g., receive 80 of 100 ordered, invoice for 100), the system creates separate MMatchPO records. The afterSave() validation throws IllegalStateException if total matched qty exceeds receipt movement qty or invoice qty.
  • Pitfall: The create() method processes matching in order of existing MMatchPO records. If multiple unmatched records exist for the same order line, the system matches in sequence, potentially creating unexpected partial matches.
  • Best Practice: Ensure receipt quantities are verified at the warehouse before processing. Use the "Match PO" and "Match Invoice" windows in iDempiere to manually review and correct matching when quantities diverge.

9.3 Period Closing with Unmatched Records

  • Pitfall: Unmatched records create Not Invoiced Receipts (NIR) balances that carry forward. If you close a period with significant NIR balances, they will distort the balance sheet.
  • Pitfall: The period must be open for DocBaseType "MXP" (Match PO) and "MXI" (Match Invoice) to create, reverse, or delete matching records. If you close these period types, you cannot perform matching corrections.
  • Best Practice: Before closing a period, run the "Not Fully Matched Invoices" report using MInvoice.getNotFullyMatchedToReceipt() to identify and resolve outstanding matches.

9.4 Credit Memo Handling

  • Pitfall: When creating MMatchPO from a credit memo (isCreditMemo() = true), the system negates the quantity: line.getQtyInvoiced().negate(). This means the match quantity is negative, which reverses the original match rather than creating a new forward match.
  • Best Practice: Always create vendor credit memos using the proper AP Credit Memo document type (DocBaseType = "APC") rather than manually creating negative AP invoices.

9.5 Attribute Set Instance (ASI) Matching

  • Pitfall: MMatchPO's create() method compares M_AttributeSetInstance_ID between existing records and new lines. If the receipt has a specific ASI (lot/serial number) but the invoice does not, the system may set the ASI on the match record from the receipt line.
  • Pitfall: If both records have different non-zero ASI values, the create() method skips the match (the continue statement is reached), potentially leaving records unmatched.
  • Best Practice: Ensure that ASI values are consistent across PO, receipt, and invoice. If using lot tracking, carry the lot number through all documents.

9.6 Multi-Currency Matching

  • Pitfall: The getInvoicePriceActual() method converts the invoice price to the order currency using the conversion rate as of the invoice date. If conversion rates are not maintained, the method throws AdempiereException with the message "ErrorConvertingCurrencyToBaseCurrency".
  • Best Practice: Ensure currency conversion rates are up to date before completing AP invoices in foreign currencies. Review PPV amounts carefully when currencies differ between PO and invoice.

10. Summary

Three-way matching in iDempiere is a comprehensive system that ensures financial integrity throughout the procurement cycle. Here are the key takeaways:

  • Two matching tables, two DocBaseTypes: M_MatchPO (MXP) links PO lines to receipt and invoice lines; M_MatchInv (MXI) links receipt lines to invoice lines.
  • MMatchPO is the hub: The MMatchPO.create() method is the central entry point called from both MInOut.completeIt() and MInvoice.completeIt(). It follows an "update or create" pattern, first looking for existing unmatched records via MatchPOAutoMatch.getNotMatchedMatchPOList().
  • Auto-creation of MMatchInv: When an MMatchPO record gains both a receipt line and an invoice line, the createMatchInv() method automatically creates the corresponding MMatchInv record within a database savepoint for safe rollback.
  • MInvoice AP constructors: AP invoices can be created from MOrder (using PO-based pricing) or from MInOut (using receipt-based references). The setBPartner() method uses the PayFrom location for AP and BillTo for AR.
  • MInvoiceLine bridges: The setOrderLine() and setShipLine() methods copy pricing and dimensions from PO lines and receipt lines respectively. The getMatchedQty() method queries M_MatchInv to determine matching status.
  • PPV is calculated in beforeSave(): MMatchPO.beforeSave() computes the price difference between the PO price and the invoice price (converted to PO currency), stores it in PriceMatchDifference, and sets IsApproved based on the BP Group's tolerance percentage.
  • Quantity validation is enforced: Both MMatchPO.afterSave() and MMatchInv.afterSave() validate that total matched quantities do not exceed the receipt's movement quantity or the invoice line's invoiced quantity, throwing IllegalStateException on violation.
  • DateAcct uses the later date: Both MMatchPO and MMatchInv set their accounting date to the later of the invoice date and the receipt date via getNewerDateAcct().
  • Reversal creates linked records: The reverse() methods on both classes create a new record with negated quantity, linked via Reversal_ID. The description fields provide cross-reference document numbers.
  • Period control uses DocBaseType: Deleting posted matching records requires the period to be open for "MXP" (Match PO) or "MXI" (Match Invoice) respectively.
  • Landed costs: The MInvoiceLine.allocateLandedCosts() method distributes freight and other charges across receipt lines based on configurable distribution rules (quantity, amount, weight, or volume).

Understanding these mechanisms at the source code level is essential for the iDempiere ERP certification exam, as matching and invoice verification questions frequently test knowledge of the exact API methods, the sequence of record creation, and the accounting implications of each matching scenario.


11. Exam Preparation: Quick Reference

Key Classes and Their Packages

Class Package Purpose
MMatchPO org.compiere.model PO-to-Receipt and PO-to-Invoice matching
MMatchInv org.compiere.model Receipt-to-Invoice matching
MInvoice org.compiere.model Invoice header (AP and AR)
MInvoiceLine org.compiere.model Invoice line with PO/receipt references
MatchPOAutoMatch org.compiere.model Utility for finding unmatched MMatchPO records
MDocType org.compiere.model Document type with DocBaseType constants
MBPGroup org.compiere.model Business Partner Group with PriceMatchTolerance

Key Constants to Remember

Constant Value Where Used
DOCBASETYPE_MatchPO "MXP" Period checks for M_MatchPO deletions
DOCBASETYPE_MatchInvoice "MXI" Period checks for M_MatchInv deletions
DOCBASETYPE_APInvoice "API" AP invoice document type
DOCBASETYPE_APCreditMemo "APC" AP credit memo document type

Critical Method Signatures to Know

// MMatchPO - Main entry point
public static MMatchPO create(MInvoiceLine iLine,
    MInOutLine sLine, Timestamp dateTrx, BigDecimal qty)

// MMatchPO - Get or create pattern
public static MMatchPO getOrCreate(int C_OrderLine_ID,
    BigDecimal qty, MInOutLine sLine, String trxName)

// MMatchPO - PPV helper
public BigDecimal getInvoicePriceActual()

// MMatchInv - Constructor from invoice line
public MMatchInv(MInvoiceLine iLine, Timestamp dateTrx,
    BigDecimal qty)

// MMatchInv - Static queries
public static MMatchInv[] get(Properties ctx,
    int M_InOutLine_ID, int C_InvoiceLine_ID, String trxName)
public static MMatchInv[] getInvoiceLine(Properties ctx,
    int C_InvoiceLine_ID, String trxName)
public static MMatchInv[] getInOutLine(Properties ctx,
    int M_InOutLine_ID, String trxName)

// MInvoiceLine - Matched qty from M_MatchInv
public BigDecimal getMatchedQty()

// MInvoiceLine - Copy from PO and Receipt
public void setOrderLine(MOrderLine oLine)
public void setShipLine(MInOutLine sLine)

// MInvoiceLine - Landed costs
public String allocateLandedCosts()

Common Exam Scenarios

  1. Scenario: PO for 100 units at $10, Receipt for 100 units, Invoice for 100 units at $12. What records are created and what is the PPV?

    Answer: MMatchPO with PriceMatchDifference = (10 - 12) * 100 = -200 (unfavorable). MMatchInv auto-created. IsApproved depends on BP Group tolerance.
  2. Scenario: PO for 100 units, Receipt for 60 units, Invoice for 100 units. What happens?

    Answer: MMatchPO for 60 units (matched to receipt). Separate MMatchPO for remaining 40 units (invoice only, no receipt). MMatchInv created for 60 units only. 40 units remain as invoice-only match until 2nd receipt.
  3. Scenario: Which date is used as DateAcct on matching records?

    Answer: The later of the invoice DateAcct and the receipt DateAcct, determined by getNewerDateAcct().
  4. Scenario: What happens if you try to delete a posted MMatchPO in a closed period?

    Answer: beforeDelete() calls MPeriod.testPeriodOpen() with DocBaseType "MXP". If the period is closed, a PeriodClosedException is thrown.

You Missed