Invoices & Billing Portal Example

Step-by-step, screenshot-driven guide to configuring a full-featured Invoices & Billing portal—end-to-end, from setup to automated publishing, user access, and payment integration.



This tutorial guides you through setting up a real-world Invoices portal. You’ll learn how to configure a rich content type that tracks Document Status, Was Viewed By, and supports payments.

Invoices are a classic master-details scenario: each invoice (master) contains multiple line items (details) for products/services ordered and invoiced.

The process follows the same steps described in
AI-Driven Portal Setup & Customizations and
Payslips Portal Example (HR) — read those for more on adapting templates, automating publishing, and verifying secure user access.

Powerful Payments & Integration:
This solution lets you publish and manage invoices, accept payments directly in the portal, and connect to any legacy database or system—no migrations required. Payment providers like PayPal, Stripe, and more are easily configurable.

Table of Contents

  1. Create the Invoice Content Type in Portal Admin UI
  2. Add an Invoice Entry for Testing
  3. single-invoice.php
  4. page-my-documents.php
  5. endExtractDocument.groovy
  6. Use ReportBurster to Publish Invoices2Portal
  7. Log In and Check Invoices as a Customer
  8. Accepting Payments & Paid/Unpaid Invoice Filtering
  9. Tips & Troubleshooting

1. Create the Invoice Content Type in Portal Admin UI

Portal admin UI showing Invoice content type and fields Caption: Define your Invoice content type and fields in the portal admin UI.

You generate invoices for customer orders. Each invoice matches a real transaction, so fields like Order ID refer to the original order.

  • Go to Pods Admin in your portal dashboard.
  • Click Add New to create a new Pod.
  • Choose Custom Post Type and name it Invoice.
  • Add all the fields needed for your billing scenario.
  • Save your Pod.

Notice the line_items_json, document_status, and was_viewed_by fields—these are the main differences from previous examples.

Note:
Pods does not support repeater fields for line items. Instead, add all line items as a JSON array in the line_items_json field.
Each object in the array should use this structure:

[
  {
    "product_name": "Widget A",
    "quantity": 2,
    "unit_price": 10.5,
    "discount": 0.1
  },
  {
    "product_name": "Widget B",
    "quantity": 1,
    "unit_price": 20.0,
    "discount": 0
  }
]

For simplicity, enter the correct values for Subtotal, Tax, and Grand Total, or calculate these automatically in your template.

2. Add an Invoice Entry for Testing

Adding a new Invoice entry in Portal Admin UI Caption: Add a sample Invoice entry for testing.

Before automating, add a sample Invoice entry to verify your setup.

  • Go to Invoices in the portal admin menu.
  • Click Add New.
  • Fill in the fields for a test customer and invoice.
  • Save the entry.

Tip:
You can skip manual entry if you prefer to automate everything. This step is just for quick testing.

3. single-invoice.php

Tip:
You don’t need to write these scripts yourself (unless you want to).
AI will generate the code for you, so you can focus on building your custom portal.

<?php
/**
 * Secure single invoice template for Pods "invoice" content type.
 * 
 * Plain PHP (WordPress, Pods Framework, Tailwind CSS)
 * Features:
 *  - Requires login.
 *  - Ownership via Pods User Relationship field: associated_user (if present).
 *  - Visual status hints (Unpaid, Paid, Viewed).
 *  - "Pay Invoice" button for unpaid/viewed invoices (non-admins only).
 *  - Admins see "Unpaid" for unpaid and "Viewed" for viewed. Admins never see "Pay Invoice".
 *  - Paid invoices: grand total is strikethrough for strong visual hint.
 *
 * OPTIONAL Pods fields you may add (all boolean/relationships can be left empty):
 *  - allow_public_view     (Boolean)            If true: anyone with URL can view (no auth required).
 *  - associated_user       (User Relationship)  Exact WP User owner.
 *  - associated_groups     (Pick / Multi-Select) One or more WP group slugs allowed (e.g. it, hr).
 *  - associated_roles      (Pick / Multi-Select) One or more WP role slugs allowed (e.g. employee, customer).
 *  - document_status       (Dropdown) One of UN|Unpaid and PA|Paid.
 *  - was_viewed_by         (Text) not_viewed or viewed_by_associated_user or viewed_by_<user_id>.
 *
 * Logic (in order):
 *  1. If allow_public_view = true => bypass all other checks.
 *  2. Otherwise user must be logged in.
 *  3. If associated_user set => only that user (or admin) allowed.
 *  4. Else if associated_groups set => user must be in at least one matching group.
 *  5. Else if associated_roles set => user must have at least one matching role.
 *  6. Else fallback: require logged-in user (already enforced).
 */
if ( ! defined('ABSPATH') ) { exit; }
 
$pod = function_exists('pods') ? pods( get_post_type(), get_the_ID() ) : null;
if ( ! $pod ) {
    wp_die('Document data unavailable.');
}
 
$doc_type = get_post_type();
 
// --- 1. Public flag (uncomment after creating the field) ---
$allow_public_view = false;
/*
$allow_public_view = (bool) $pod->field('allow_public_view');
*/
 
// --- 2. Require login unless public ---
if ( ! $allow_public_view && ! is_user_logged_in() ) {
    auth_redirect(); // redirects & exits
    exit;
}
 
$current_user = wp_get_current_user();
$is_admin     = current_user_can('administrator');
 
// Collect intended access controls (may be empty if fields not defined)
$associated_user_id  = 0;
/*
$associated_user_id = (int) $pod->field('associated_user.ID');
*/
$associated_groups_ids = [];
/*
$associated_groups_ids = (array) $pod->field('associated_groups'); // adjust depending on Pods storage
*/
$associated_roles = [];
/*
$raw_roles = $pod->field('associated_roles');
$associated_roles = is_array($raw_roles) ? $raw_roles : ( $raw_roles ? [ $raw_roles ] : [] );
*/
 
// --- 3–5. Conditional enforcement (skip if public or admin) ---
if ( ! $allow_public_view && ! $is_admin ) {
 
    // 3. Exact user ownership
    if ( $associated_user_id ) {
        if ( get_current_user_id() !== $associated_user_id ) {
            wp_die('Not authorized (owner mismatch).');
        }
    }
    // 4. Group membership (placeholder – implement your own check)
    elseif ( $associated_groups_ids ) {
        /*
        // Example placeholder:
        $user_group_ids = []; // TODO: fetch groups for current user.
        if ( ! array_intersect( $associated_groups_ids, $user_group_ids ) ) {
            wp_die('Not authorized (group mismatch).');
        }
        */
    }
    // 5. Role-based access
    elseif ( $associated_roles ) {
        $user_roles = (array) $current_user->roles;
        if ( ! array_intersect( $associated_roles, $user_roles ) ) {
            wp_die('Not authorized (role mismatch).');
        }
    }
    // 6. Else: already logged in so allowed.
}
 
// ---------------- DATA FIELDS (fetch from invoice pod) ----------------
$order_id      = esc_html( (string) $pod->display('order_id') );
$order_date    = esc_html( (string) $pod->display('order_date') );
$customer_id   = esc_html( (string) $pod->display('customer_id') );
$customer_name = esc_html( (string) $pod->display('customer_name') );
$freight       = number_format( (float) $pod->field('freight'), 2 );
$subtotal      = number_format( (float) $pod->field('subtotal'), 2 );
$tax           = number_format( (float) $pod->field('tax'), 2 );
$grand_total   = number_format( (float) $pod->field('grand_total'), 2 );
 
// Invoice Status logic (codes: UN|Unpaid, PA|Paid)
$document_status = '';
$status_label = '';
$status_color = '';
$status = $pod->field('document_status');
if (is_array($status)) {
    $document_status = isset($status['name']) ? $status['name'] : '';
    $document_status_code = isset($status['slug']) ? strtoupper($status['slug']) : strtoupper($document_status);
} else {
    $document_status = (string)$status;
    $document_status_code = strtoupper($document_status);
}
switch ($document_status_code) {
    case 'UN':
    case 'UNPAID':
        $status_label = 'Unpaid';
        $status_color = 'bg-gray-100 text-gray-800 border-gray-300';
        break;
    case 'PA':
    case 'PAID':
        $status_label = 'Paid';
        $status_color = 'bg-gray-100 text-gray-800 border-gray-300';
        break;
    default:
        $status_label = ucfirst($document_status);
        $status_color = 'bg-gray-100 text-gray-800 border-gray-300';
        break;
}
 
// --- Was viewed logic (use was_viewed_by field) ---
$was_viewed_by = (string) $pod->field('was_viewed_by');
$was_viewed = (strpos($was_viewed_by, 'viewed_by_associated_user') !== false);
 
// Parse line items JSON
$line_items_json = $pod->field('line_items_json');
$line_items = [];
if ($line_items_json) {
    $decoded = json_decode($line_items_json, true);
    if (is_array($decoded)) {
        $line_items = $decoded;
    }
}
 
// Load theme header (includes Tailwind and other assets)
get_header();
?>
 
<div class="max-w-3xl mx-auto bg-white font-sans text-gray-900 p-8 rounded-lg shadow-lg my-10">
  <!-- Company Info -->
  <div class="text-center mb-6">
    <div class="text-white bg-blue-900 py-2 rounded-t-lg font-semibold text-lg tracking-wide">Northridge Pharmaceuticals</div>
    <div class="bg-blue-900 text-white py-1">7649F Diamond Hts Blvd</div>
    <div class="bg-blue-900 text-white py-1">San Francisco</div>
    <div class="bg-blue-900 text-white py-1 rounded-b-lg">(415) 872-9214</div>
  </div>
 
  <!-- Invoice Header -->
  <div class="flex flex-col sm:flex-row sm:justify-between items-center mb-6">
    <div>
      <h1 class="text-2xl font-bold text-blue-900 mb-1">Invoice <?php echo $order_id; ?></h1>
      <div class="text-gray-600 text-sm">Date: <?php echo $order_date; ?></div>
    </div>
    <div class="mt-2 sm:mt-0 flex items-center gap-2">
      <?php if ($document_status_code === 'UN' || $document_status_code === 'UNPAID'): ?>
        <span class="inline-block px-4 py-1 border rounded <?php echo $status_color; ?> font-semibold text-base">
          <?php echo esc_html($status_label); ?>
        </span>
      <?php elseif ($document_status_code === 'PA' || $document_status_code === 'PAID'): ?>
        <span class="inline-block px-4 py-1 border rounded <?php echo $status_color; ?> font-semibold text-base">
          <?php echo esc_html($status_label); ?>
        </span>
      <?php endif; ?>
      <?php
        // Show "Viewed" (eye icon) only for admins if was_viewed is true
        if ($is_admin && $was_viewed): ?>
        <span class="inline-flex items-center px-3 py-1 border border-gray-300 bg-gray-100 text-gray-700 rounded font-semibold text-base ml-2">
          <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
            <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
            <path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
          </svg>
          Viewed
        </span>
      <?php endif; ?>
    </div>
  </div>
 
  <!-- Customer Info -->
  <div class="mb-6">
    <div class="text-lg font-semibold text-gray-800">Billed To:</div>
    <div class="ml-2 text-gray-700">
      <div><span class="font-medium">Customer:</span> <?php echo $customer_id; ?> (<?php echo $customer_name; ?>)</div>
    </div>
  </div>
 
  <!-- Line Items Table -->
  <h2 class="text-lg font-bold text-blue-900 mb-2">Details</h2>
  <div class="overflow-x-auto">
    <table class="min-w-full border border-gray-200 rounded-lg mb-6">
      <thead>
        <tr class="bg-gray-50">
          <th class="px-4 py-2 text-left text-xs font-semibold text-gray-700">Product</th>
          <th class="px-4 py-2 text-right text-xs font-semibold text-gray-700">Quantity</th>
          <th class="px-4 py-2 text-right text-xs font-semibold text-gray-700">Unit Price</th>
          <th class="px-4 py-2 text-right text-xs font-semibold text-gray-700">Discount</th>
          <th class="px-4 py-2 text-right text-xs font-semibold text-gray-700">Line Total</th>
        </tr>
      </thead>
      <tbody>
        <?php
        $calculated_subtotal = 0.0;
        foreach ($line_items as $item):
            $product = isset($item['product_name']) ? esc_html($item['product_name']) : '';
            $qty = isset($item['quantity']) ? (float)$item['quantity'] : 0;
            $unit_price = isset($item['unit_price']) ? (float)$item['unit_price'] : 0;
            $discount = isset($item['discount']) ? (float)$item['discount'] : 0;
            $line_total = ($qty * $unit_price) - $discount;
            $calculated_subtotal += $line_total;
        ?>
        <tr class="border-b last:border-b-0 hover:bg-gray-50">
          <td class="px-4 py-2"><?php echo $product; ?></td>
          <td class="px-4 py-2 text-right"><?php echo $qty; ?></td>
          <td class="px-4 py-2 text-right">$<?php echo number_format($unit_price, 2); ?></td>
          <td class="px-4 py-2 text-right">
            <?php echo $discount ? '$' . number_format($discount, 2) : '-'; ?>
          </td>
          <td class="px-4 py-2 text-right">$<?php echo number_format($line_total, 2); ?></td>
        </tr>
        <?php endforeach; ?>
      </tbody>
      <tfoot>
        <tr class="bg-gray-100 font-semibold">
          <td colspan="4" class="text-right px-4 py-2">Subtotal:</td>
          <td class="text-right px-4 py-2">$<?php echo $subtotal; ?></td>
        </tr>
        <tr>
          <td colspan="4" class="text-right px-4 py-2">Freight:</td>
          <td class="text-right px-4 py-2">$<?php echo $freight; ?></td>
        </tr>
        <tr>
          <td colspan="4" class="text-right px-4 py-2">Tax:</td>
          <td class="text-right px-4 py-2">$<?php echo $tax; ?></td>
        </tr>
        <tr class="bg-blue-50 font-bold">
          <td colspan="4" class="text-right px-4 py-2">Grand Total:</td>
          <td class="text-right px-4 py-2">
            <?php if ($document_status_code === 'PA' || $document_status_code === 'PAID'): ?>
              <span style="text-decoration: line-through; color: #888;">$<?php echo $grand_total; ?></span>
            <?php else: ?>
              $<?php echo $grand_total; ?>
            <?php endif; ?>
          </td>
        </tr>
      </tfoot>
    </table>
  </div>
 
  <!-- Pay Invoice Button (for Unpaid or Viewed, only for non-admins) -->
  <?php if (
    !$is_admin &&
    (
      $document_status_code === 'UN' ||
      $document_status_code === 'UNPAID' ||
      $was_viewed
    )
  ): ?>
    <div class="flex justify-end mt-8">
      <a href="#"
         class="inline-block px-8 py-3 rounded bg-blue-600 text-white font-bold text-lg shadow hover:bg-blue-700 transition"
         style="letter-spacing:0.5px;">
        Pay Invoice
      </a>
    </div>
  <?php endif; ?>
 
  <!-- Actions -->
  <div class="mt-10 flex justify-center gap-4 print:hidden">
    <?php
      $account_page_id = (int) get_option('reportburster_account_page_id');
      $back_url = $account_page_id
        ? get_permalink($account_page_id)
        : ( get_permalink( get_page_by_path('my-documents') ) ?: home_url() );
    ?>
    <a href="<?php echo esc_url( $back_url ); ?>"
       class="inline-block px-5 py-2 rounded bg-gray-700 text-white hover:bg-gray-800 transition">
      Back to Documents
    </a>
    <a class="inline-block px-5 py-2 rounded bg-blue-600 text-white hover:bg-blue-700 transition"
       href="javascript:window.print();">
      Print
    </a>
  </div>
</div>
 
<?php
// Load theme footer (includes scripts and closes HTML)
get_footer();
?>

4. page-my-documents.php

Tip:
You don’t need to write these scripts yourself (unless you want to).
AI will generate the code for you, so you can focus on building your custom portal.

<?php
/**
 * My Invoices (Invoices list)
 * Plain PHP (WordPress, Pods Framework, Tailwind CSS)
 * Features:
 *  - Requires login.
 *  - Lists invoices user may view.
 *  - Ownership via Pods User Relationship field: associated_user (if present).
 *  - Pagination (?page=N) & search (?q=term) on customer name, order id, or date.
 *  - Visual status hints (Unpaid, Paid, Viewed).
 *  - "Pay Invoice" button for unpaid/viewed invoices (non-admins only).
 *  - Admins see "Unpaid" for unpaid and "Viewed" for viewed. Admins never see "Pay Invoice".
 *  - Paid invoices: grand total is strikethrough for strong visual hint.
 */
 
if ( ! defined('ABSPATH') ) { exit; }
if ( ! is_user_logged_in() ) { auth_redirect(); exit; }
 
$current_user     = wp_get_current_user();
$current_user_id  = (int) $current_user->ID;
$user_roles       = (array) $current_user->roles;
$is_admin         = current_user_can('administrator');
 
$post_type        = 'invoice';
$ownership_field  = 'associated_user'; // Pods User Relationship field name
$per_page         = 15;
$page_param       = 'page';
$search_param     = 'q';
 
$paged        = max( 1, (int) ( $_GET[$page_param] ?? 1 ) );
$search_term  = isset($_GET[$search_param]) ? sanitize_text_field( wp_unslash( $_GET[$search_param] ) ) : '';
$offset       = ( $paged - 1 ) * $per_page;
 
$invoices_rows   = [];
$invoices_found  = false;
$total_found     = 0;
$total_pages     = 1;
$filtered_notice = '';
 
/**
 * Detect ownership field existence via Pods schema (not meta scan).
 */
$ownership_field_exists = false;
if ( function_exists('pods_api') ) {
    $schema = pods_api()->load_pod( [ 'name' => $post_type ] );
    if ( isset( $schema['fields'][ $ownership_field ] ) ) {
        $ownership_field_exists = true;
    }
}
 
/**
 * Query only if Pods active and user role allowed.
 */
if ( function_exists('pods') ) {
 
    $where = [];
 
    if ( $ownership_field_exists ) {
        if ( ! $is_admin ) {
            $where[] = "{$ownership_field}.ID = {$current_user_id}";
            $filtered_notice = '(Filtered to your invoices)';
        } else {
            $filtered_notice = '(Admin – unfiltered)';
        }
    } else {
        if ( ! $is_admin ) {
            // Hide list from non-admins until ownership field defined
            $where[] = "ID = 0";
            $filtered_notice = '(Ownership field missing)';
        } else {
            $filtered_notice = '(Admin – ownership field missing, list unfiltered)';
        }
    }
 
    if ( $search_term !== '' ) {
        $like   = '%' . esc_sql( $search_term ) . '%';
        $where[] = "( customer_name LIKE '{$like}' OR order_id LIKE '{$like}' OR order_date LIKE '{$like}' )";
    }
 
    $params = [
        'limit'   => $per_page,
        'offset'  => $offset,
        'orderby' => 'post_date DESC',
    ];
    if ( $where ) {
        $params['where'] = implode( ' AND ', $where );
    }
 
    $pod = pods( $post_type, $params );
 
    if ( $pod ) {
        $total_found    = (int) $pod->total();
        $invoices_found = $total_found > 0;
        $total_pages    = max( 1, (int) ceil( $total_found / $per_page ) );
 
        if ( $invoices_found ) {
            while ( $pod->fetch() ) {
                $pid           = (int) $pod->id();
                $order_id      = (string) $pod->display( 'order_id' );
                $order_date    = (string) $pod->display( 'order_date' );
                $customer_name = (string) $pod->display( 'customer_name' );
                $grand_total   = (float) $pod->field( 'grand_total' );
                $status        = $pod->field( 'document_status' );
                $status_code   = '';
                $status_label  = '';
                $status_color  = '';
                if (is_array($status)) {
                    $status_code = isset($status['slug']) ? strtoupper($status['slug']) : strtoupper($status['name']);
                } else {
                    $status_code = strtoupper((string)$status);
                }
 
                // --- Was viewed logic (use was_viewed_by field) ---
                $was_viewed_by = (string) $pod->field('was_viewed_by');
                $was_viewed = (strpos($was_viewed_by, 'viewed_by_associated_user') !== false);
 
                switch ($status_code) {
                    case 'UN':
                    case 'UNPAID':
                        $status_label = $is_admin ? 'Unpaid' : 'Unpaid';
                        $status_color = 'bg-gray-100 text-gray-800 border border-gray-300';
                        break;
                    case 'PA':
                    case 'PAID':
                        $status_label = 'Paid';
                        $status_color = 'bg-gray-100 text-gray-800 border border-gray-300';
                        break;
                    default:
                        $status_label = ucfirst(strtolower($status_code));
                        $status_color = 'bg-gray-100 text-gray-800 border border-gray-300';
                        break;
                }
                $invoices_rows[] = [
                    'order_id'      => esc_html( $order_id ),
                    'order_date'    => esc_html( $order_date ),
                    'customer_name' => esc_html( $customer_name ),
                    'grand_total'   => $grand_total ? '$' . number_format( $grand_total, 2 ) : '',
                    'status_code'   => $status_code,
                    'status_label'  => $status_label,
                    'status_color'  => $status_color,
                    'link'          => esc_url( get_permalink( $pid ) ),
                    'was_viewed'    => $was_viewed,
                ];
            }
        }
    }
}
 
/**
 * Simple pagination
 */
function my_invoices_paginate( int $current, int $total, string $param = 'page' ): void {
    if ( $total < 2 ) return;
    echo '<nav class="flex justify-center mt-4 space-x-2 text-sm">';
    for ( $i = 1; $i <= $total; $i++ ) {
        $url = esc_url( add_query_arg( $param, $i ) );
        if ( $i === $current ) {
            echo '<span class="px-3 py-1 rounded bg-blue-600 text-white font-semibold">'.$i.'</span>';
        } else {
            echo '<a href="'.$url.'" class="px-3 py-1 rounded bg-gray-100 hover:bg-blue-100 text-blue-700">'.$i.'</a>';
        }
    }
    echo '</nav>';
}
 
// Load theme header (includes Tailwind and other assets)
get_header();
?>
 
<div class="max-w-4xl mx-auto py-8">
  <div class="flex justify-between items-center mb-6">
    <div class="text-sm text-gray-700">
      Logged in as <strong><?php echo esc_html( $current_user->display_name ); ?></strong>
    </div>
    <a class="text-red-600 hover:underline text-sm" href="<?php echo esc_url( wp_logout_url( home_url() ) ); ?>">Logout</a>
  </div>
 
  <h1 class="text-2xl font-bold mb-2">My Invoices</h1>
  <p class="text-sm text-gray-600 mb-4">
    Invoices you are authorized to view.
    <?php if ( $filtered_notice ) : ?>
      <span class="inline-block bg-blue-100 text-blue-800 px-2 py-0.5 rounded ml-2 text-xs"><?php echo esc_html( $filtered_notice ); ?></span>
    <?php endif; ?>
    <?php if ( $search_term !== '' ) : ?>
      <span class="inline-block bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded ml-2 text-xs">Search:<?php echo esc_html( $search_term ); ?></span>
    <?php endif; ?>
  </p>
 
  <div class="bg-white border border-gray-200 rounded-lg shadow-sm p-6">
    <div class="flex items-center justify-between mb-4">
      <h2 class="text-lg font-semibold mb-0">Invoices
        <?php if ( $invoices_found ): ?>
          <span class="ml-2 text-xs text-gray-500">(<?php echo (int) $total_found; ?> total)</span>
        <?php endif; ?>
      </h2>
      <form method="get" class="flex gap-2 items-center">
        <input
          type="text"
          name="<?php echo esc_attr( $search_param ); ?>"
          value="<?php echo esc_attr( $search_term ); ?>"
          placeholder="Search customer, order id, or date..."
          class="border border-gray-300 rounded px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
        />
        <button type="submit" class="px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700">Search</button>
        <?php if ( $search_term !== '' ): ?>
          <a href="<?php echo esc_url( remove_query_arg( $search_param ) ); ?>" class="ml-2 text-blue-600 hover:underline text-xs">Reset</a>
        <?php endif; ?>
      </form>
    </div>
 
    <?php if ( $invoices_found ): ?>
      <div class="overflow-x-auto">
        <table class="min-w-full border border-gray-200 rounded-lg">
          <thead>
            <tr class="bg-gray-50">
              <th class="px-4 py-2 text-center text-xs font-semibold text-gray-700" style="width:60px;">View</th>
              <th class="px-4 py-2 text-left text-xs font-semibold text-gray-700">Order ID</th>
              <th class="px-4 py-2 text-left text-xs font-semibold text-gray-700">Date</th>
              <th class="px-4 py-2 text-left text-xs font-semibold text-gray-700">Customer</th>
              <th class="px-4 py-2 text-right text-xs font-semibold text-gray-700">Total</th>
              <th class="px-4 py-2 text-center text-xs font-semibold text-gray-700" style="width:120px;">Status / Action</th>
            </tr>
          </thead>
          <tbody>
          <?php foreach ( $invoices_rows as $row ): ?>
            <tr class="border-b last:border-b-0 hover:bg-gray-50">
              <td class="px-4 py-2 text-center">
                <a href="<?php echo $row['link']; ?>" class="text-blue-600 hover:underline font-semibold text-xs">
                  View
                </a>
              </td>
              <td class="px-4 py-2"><?php echo $row['order_id']; ?></td>
              <td class="px-4 py-2"><?php echo $row['order_date']; ?></td>
              <td class="px-4 py-2"><?php echo $row['customer_name']; ?></td>
              <td class="px-4 py-2 text-right">
                <?php if ($row['status_code'] === 'PA' || $row['status_code'] === 'PAID'): ?>
                  <span style="text-decoration: line-through; color: #888;"><?php echo $row['grand_total']; ?></span>
                <?php else: ?>
                  <?php echo $row['grand_total']; ?>
                <?php endif; ?>
              </td>
              <td class="px-4 py-2 text-center">
                <?php
                // Show "Paid" for paid, "Unpaid" for admin on unpaid, "Viewed" for admin if was_viewed, "Pay Invoice" for non-admin on unpaid or viewed
                if ($row['status_code'] === 'PA' || $row['status_code'] === 'PAID'): ?>
                  <span class="inline-flex items-center px-4 py-1 border border-gray-300 bg-gray-200 text-gray-800 rounded font-semibold text-xs shadow"
                        style="min-width:80px;text-align:center;">
                    <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
                      <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
                    </svg>&nbsp;Paid&nbsp;&nbsp;&nbsp;&nbsp;
                  </span>
                <?php elseif ($is_admin && $row['was_viewed'] && ($row['status_code'] === 'UN' || $row['status_code'] === 'UNPAID')): ?>
                  <span class="inline-flex items-center px-4 py-1 border border-gray-300 bg-gray-200 text-gray-800 rounded font-semibold text-xs shadow"
                        style="min-width:80px;text-align:center;">
                    <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
                      <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
                      <path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
                    </svg>
                    Viewed
                  </span>
                <?php elseif (!$is_admin && ($row['status_code'] === 'UN' || $row['status_code'] === 'UNPAID' || $row['was_viewed'])): ?>
                  <a href="#"
                     class="inline-block px-4 py-1 rounded bg-blue-600 text-white font-semibold text-xs shadow hover:bg-blue-700 transition"
                     style="min-width:80px;text-align:center;">
                    Pay Invoice
                  </a>
                <?php elseif ($is_admin && ($row['status_code'] === 'UN' || $row['status_code'] === 'UNPAID')): ?>
                  <span class="inline-block px-4 py-1 rounded bg-gray-200 text-gray-800 font-semibold text-xs border border-gray-300 shadow"
                        style="min-width:80px;text-align:center;">
                    &nbsp;&nbsp;Unpaid&nbsp;&nbsp;&nbsp;&nbsp;
                  </span>
                <?php else: ?>
                  <span class="inline-block px-4 py-1 rounded bg-gray-200 text-gray-700 font-semibold text-xs"
                        style="min-width:80px;text-align:center;">
                    <?php echo esc_html($row['status_label']); ?>
                  </span>
                <?php endif; ?>
              </td>
            </tr>
          <?php endforeach; ?>
          </tbody>
        </table>
      </div>
      <?php my_invoices_paginate( $paged, $total_pages, $page_param ); ?>
    <?php else: ?>
      <div class="py-6 text-center text-gray-500 italic">
        <?php if ( $search_term !== '' ): ?>
          No invoices match “<?php echo esc_html( $search_term ); ?>.
        <?php elseif ( ! $is_admin ): ?>
          No invoices available for your role.
        <?php else: ?>
          No invoices found.
        <?php endif; ?>
      </div>
    <?php endif; ?>
  </div>
</div>
 
<?php
// Load theme footer (includes scripts and closes HTML)
get_footer();
?>

5. endExtractDocument.groovy

Automate the creation of invoices and users via API.

Tip:
You don’t need to write these scripts yourself (unless you want to).
AI will generate the code for you, so you can focus on building your custom portal.

/*
 * Groovy Script for ReportBurster: Publish Invoice to WordPress Portal
 *
 * This script creates a new 'invoice' post in WordPress via REST API,
 * checking or creating the associated user as needed.
 *
 * Assumptions:
 * - Field values from user variables:
 *   var0=order_id, var1=order_date, var2=customer_id, var3=customer_name, var4=freight, var5=line_items_json,
 *   var6=subtotal, var7=tax, var8=grand_total, var9=associated_user, var10=document_status, var11=was_viewed_by
 * - Authentication via Basic Auth (username:password).
 *
 * Inputs (replace placeholders):
 * - API_ENDPOINT: e.g., 'http://localhost:8080/wp-json/wp/v2/invoice'
 * - AUTH_METHOD: e.g., 'admin:password' for Basic Auth
 */
 
import groovy.ant.AntBuilder
import com.sourcekraft.documentburster.variables.Variables
 
// Inputs
def apiEndpoint = '[PASTE_API_ENDPOINT_HERE]'
def authMethod = '[API_KEY_OR_METHOD]'
 
// Burst context
def token = ctx.token
 
// Extract field values from user variables
def orderId        = ctx.variables.getUserVariables(ctx.token).get("var0")
def orderDate      = ctx.variables.getUserVariables(ctx.token).get("var1")
def customerId     = ctx.variables.getUserVariables(ctx.token).get("var2")
def customerName   = ctx.variables.getUserVariables(ctx.token).get("var3")
def freight        = ctx.variables.getUserVariables(ctx.token).get("var4").toFloat()
def lineItemsJson  = ctx.variables.getUserVariables(ctx.token).get("var5") // Should be valid JSON string
def subtotal       = ctx.variables.getUserVariables(ctx.token).get("var6").toFloat()
def tax            = ctx.variables.getUserVariables(ctx.token).get("var7").toFloat()
def grandTotal     = ctx.variables.getUserVariables(ctx.token).get("var8").toFloat()
def associatedUser = ctx.variables.getUserVariables(ctx.token).get("var9")
def documentStatus = ctx.variables.getUserVariables(ctx.token).get("var10")
def wasViewedBy    = ctx.variables.getUserVariables(ctx.token).get("var11")
 
def ant = new AntBuilder()
 
// Step 1: Check/Create User
log.info("Step 1: Checking/Creating WordPress User")
def userCheckEndpoint = 'http://localhost:8080/wp-json/wp/v2/users'
def userEmail = associatedUser + '@example.com'
def userExists = false
 
def checkUserCmd = "-u ${authMethod} -X GET \"${userCheckEndpoint}?search=${associatedUser}\""
log.info("Checking user: curl.exe ${checkUserCmd}")
ant.exec(
    append: true,
    failonerror: false,
    output: "logs/user_check.log",
    executable: 'tools/curl/win/curl.exe'
) {
    arg(line: checkUserCmd)
}
def userCheckLog = new File("logs/user_check.log").text
if (userCheckLog.contains('"id"')) {
    userExists = true
    log.info("User exists.")
} else {
    def createUserData = "{\"username\":\"${associatedUser}\", \"email\":\"${userEmail}\", \"password\":\"defaultpass123\", \"roles\":[\"customer\"]}"
    def createUserCmd = "-u ${authMethod} -X POST -H \"Content-Type: application/json\" -d \"${createUserData}\" \"${userCheckEndpoint}\""
    log.info("Creating user: curl.exe ${createUserCmd}")
    ant.exec(
        append: true,
        failonerror: true,
        output: "logs/user_create.log",
        executable: 'tools/curl/win/curl.exe'
    ) {
        arg(line: createUserCmd)
    }
    log.info("User created.")
}
 
// Step 2: Prepare and Publish Invoice Post
log.info("Step 2: Preparing and Publishing Invoice Post")
def postData = [
    title: "Invoice ${orderId}",
    status: 'publish',
    meta: [
        order_id: orderId,
        order_date: orderDate,
        customer_id: customerId,
        customer_name: customerName,
        freight: freight,
        line_items_json: lineItemsJson,
        subtotal: subtotal,
        tax: tax,
        grand_total: grandTotal,
        associated_user: associatedUser,
        document_status: documentStatus,
        was_viewed_by: wasViewedBy
    ]
]
def jsonData = groovy.json.JsonBuilder(postData).toString()
 
def publishCmd = "-u ${authMethod} -X POST -H \"Content-Type: application/json\" -d \"${jsonData}\" \"${apiEndpoint}\""
log.info("Publishing invoice: curl.exe ${publishCmd}")
ant.exec(
    append: true,
    failonerror: true,
    output: "logs/publish.log",
    executable: 'tools/curl/win/curl.exe'
) {
    arg(line: publishCmd)
}
log.info("Invoice published successfully.")
 
// Log result
def publishLog = new File("logs/publish.log").text
log.info("Publish result: ${publishLog}")

6. Use ReportBurster to Publish Invoices2Portal

ReportBurster Portal Invoices2Portal execution Caption: Run ReportBurster to publish invoices to the portal.

ReportBurster Portal Invoices2Portal Yes Upload Caption: Confirm upload of invoices.

Invoices are uploaded to the portal admin.

Portal admin showing new invoices Caption: New invoices appear in the portal admin.

ReportBurster works with any reporting or business software, including Crystal Reports, SAP, Oracle, Microsoft Dynamics, and more.
You can generate reports and upload to ReportBurster2Portal from any datasource.

7. Log In and Check Invoices as a Customer

Now, switch to the frontend—the experience your customers will see.

User login screen Caption: Customer login screen.

Invoices are protected—users can only see their own.

My Documents page for invoices Caption: Customers see only their own invoices.

Single Invoice view.

Single invoice view Caption: Viewing a single invoice.

8. Accepting Payments & Paid/Unpaid Invoice Filtering

Your portal can accept payments for published invoices and filter by payment status (paid/unpaid/overdue).
A wide range of payment providers are easily configurable, including PayPal, Stripe, and others.

Want to accept payments and track paid/unpaid invoices?
This solution supports direct payment integration and filtering—just ask us how to enable it for your scenario!

You can connect the portal directly to your legacy database or CRM, so invoices and payments flow seamlessly—no migrations or complex integrations needed.

9. Tips & Troubleshooting

  • Field names must match: Make sure your content type fields and script fields match exactly.
  • Ownership matters: The associated_user field must be set for access control to work.
  • Template errors: If you see blank pages, check your PHP templates for typos.
  • Portal API issues: Double-check authentication and endpoint URLs.
  • Need more help? See the AI-Driven Portal Setup & Customizations guide.

You’ve now built a full-featured, secure Invoices & Billing portal—end-to-end, with automation, user protection, and the power to accept payments!