Add WooCommerce integration for payments, invoices, and order management (v0.11.0)
All checks were successful
Create Release Package / build-release (push) Successful in 1m11s

- Product sync: Virtual WC products for rooms with bidirectional linking
- Cart/Checkout: Booking data in cart items, availability validation, dynamic pricing
- Orders: Automatic booking creation on payment, status mapping, guest record creation
- Invoices: PDF generation via mPDF, auto-attach to emails, configurable numbering
- Refunds: Full refund cancels booking, partial refund records amount only
- Admin: Cross-linked columns and row actions between bookings and orders
- Settings: WooCommerce tab with subtabs (General, Products, Orders, Invoices)
- HPOS compatibility declared for High-Performance Order Storage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 22:40:36 +01:00
parent 965060cc03
commit 2865956c56
15 changed files with 5036 additions and 9 deletions

View File

@@ -0,0 +1,633 @@
<?php
/**
* WooCommerce Invoice Generator.
*
* Generates PDF invoices for WooCommerce orders.
*
* @package Magdev\WpBnb\Integration\WooCommerce
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Integration\WooCommerce;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\Pricing\Calculator;
use Mpdf\Mpdf;
/**
* Invoice Generator class.
*
* Creates PDF invoices for bookings using mPDF.
*/
final class InvoiceGenerator {
/**
* Invoice storage directory relative to uploads.
*/
private const INVOICE_DIR = 'wp-bnb-invoices';
/**
* Initialize invoice generator.
*
* @return void
*/
public static function init(): void {
// Attach invoice to order emails.
add_filter( 'woocommerce_email_attachments', array( self::class, 'attach_invoice_to_email' ), 10, 4 );
// Add admin order action button.
add_action( 'woocommerce_admin_order_actions_end', array( self::class, 'add_order_action_button' ) );
// AJAX handler for invoice generation.
add_action( 'wp_ajax_wp_bnb_generate_invoice', array( self::class, 'ajax_generate_invoice' ) );
// Handle invoice download.
add_action( 'init', array( self::class, 'handle_invoice_download' ) );
}
/**
* Generate invoice for an order.
*
* @param \WC_Order $order WooCommerce order.
* @return string|null Invoice file path or null on failure.
*/
public static function generate_invoice( \WC_Order $order ): ?string {
/**
* Fires before generating an invoice.
*
* @param \WC_Order $order WooCommerce order.
*/
do_action( 'wp_bnb_wc_before_invoice_generate', $order );
// Get or generate invoice number.
$invoice_number = self::get_invoice_number( $order );
// Get booking data.
$booking_id = Manager::get_booking_for_order( $order );
// Generate HTML content.
$html = self::get_invoice_html( $order, $invoice_number, $booking_id );
// Generate PDF.
try {
$temp_dir = get_temp_dir() . 'mpdf';
if ( ! file_exists( $temp_dir ) ) {
wp_mkdir_p( $temp_dir );
}
$mpdf = new Mpdf(
array(
'mode' => 'utf-8',
'format' => 'A4',
'margin_left' => 15,
'margin_right' => 15,
'margin_top' => 15,
'margin_bottom' => 20,
'tempDir' => $temp_dir,
)
);
$mpdf->SetTitle( sprintf( 'Invoice %s', $invoice_number ) );
$mpdf->SetAuthor( get_option( 'wp_bnb_business_name', get_bloginfo( 'name' ) ) );
$mpdf->SetCreator( 'WP BnB' );
$mpdf->WriteHTML( $html );
// Save to file.
$file_path = self::get_invoice_path( $order, $invoice_number );
$mpdf->Output( $file_path, 'F' );
// Store invoice number and path in order meta.
$order->update_meta_data( '_bnb_invoice_number', $invoice_number );
$order->update_meta_data( '_bnb_invoice_path', $file_path );
$order->update_meta_data( '_bnb_invoice_date', current_time( 'mysql' ) );
$order->save();
/**
* Fires after generating an invoice.
*
* @param \WC_Order $order WooCommerce order.
* @param string $file_path Invoice file path.
*/
do_action( 'wp_bnb_wc_after_invoice_generate', $order, $file_path );
return $file_path;
} catch ( \Exception $e ) {
error_log( 'WP BnB Invoice generation failed: ' . $e->getMessage() );
return null;
}
}
/**
* Get invoice number for an order.
*
* @param \WC_Order $order WooCommerce order.
* @return string Invoice number.
*/
public static function get_invoice_number( \WC_Order $order ): string {
// Check if already has invoice number.
$existing = $order->get_meta( '_bnb_invoice_number', true );
if ( $existing ) {
return $existing;
}
// Generate new invoice number.
return Manager::get_next_invoice_number();
}
/**
* Get invoice file path.
*
* @param \WC_Order $order WooCommerce order.
* @param string $invoice_number Invoice number.
* @return string File path.
*/
private static function get_invoice_path( \WC_Order $order, string $invoice_number ): string {
$upload_dir = wp_upload_dir();
$invoice_dir = $upload_dir['basedir'] . '/' . self::INVOICE_DIR;
// Create directory if needed.
if ( ! file_exists( $invoice_dir ) ) {
wp_mkdir_p( $invoice_dir );
// Add .htaccess to protect invoices.
$htaccess = $invoice_dir . '/.htaccess';
if ( ! file_exists( $htaccess ) ) {
file_put_contents( $htaccess, 'Deny from all' );
}
// Add index.php for extra protection.
$index = $invoice_dir . '/index.php';
if ( ! file_exists( $index ) ) {
file_put_contents( $index, '<?php // Silence is golden.' );
}
}
// Sanitize invoice number for filename.
$safe_number = sanitize_file_name( $invoice_number );
return $invoice_dir . '/invoice-' . $safe_number . '-' . $order->get_id() . '.pdf';
}
/**
* Check if invoice exists for an order.
*
* @param \WC_Order $order WooCommerce order.
* @return bool
*/
public static function invoice_exists( \WC_Order $order ): bool {
$path = $order->get_meta( '_bnb_invoice_path', true );
return $path && file_exists( $path );
}
/**
* Attach invoice to email.
*
* @param array $attachments Attachments array.
* @param string $email_id Email ID.
* @param \WC_Order $order WooCommerce order.
* @param \WC_Email $email Email object.
* @return array Modified attachments.
*/
public static function attach_invoice_to_email( array $attachments, string $email_id, $order, $email ): array {
// Only attach to specific emails.
$allowed_emails = array(
'customer_completed_order',
'customer_processing_order',
'customer_invoice',
);
if ( ! in_array( $email_id, $allowed_emails, true ) ) {
return $attachments;
}
// Check if order is a WC_Order.
if ( ! $order instanceof \WC_Order ) {
return $attachments;
}
// Check if invoice attachment is enabled.
if ( ! Manager::is_invoice_attach_enabled() ) {
return $attachments;
}
// Check if this order has a booking.
$booking_id = Manager::get_booking_for_order( $order );
if ( ! $booking_id ) {
return $attachments;
}
// Generate invoice if it doesn't exist.
if ( ! self::invoice_exists( $order ) ) {
self::generate_invoice( $order );
}
// Get invoice path.
$invoice_path = $order->get_meta( '_bnb_invoice_path', true );
if ( $invoice_path && file_exists( $invoice_path ) ) {
$attachments[] = $invoice_path;
}
return $attachments;
}
/**
* Add order action button for invoice.
*
* @param \WC_Order $order WooCommerce order.
* @return void
*/
public static function add_order_action_button( \WC_Order $order ): void {
// Check if this order has a booking.
$booking_id = Manager::get_booking_for_order( $order );
if ( ! $booking_id ) {
return;
}
// Generate download URL.
$download_url = add_query_arg(
array(
'bnb_download_invoice' => $order->get_id(),
'_wpnonce' => wp_create_nonce( 'bnb_download_invoice_' . $order->get_id() ),
),
admin_url( 'admin.php' )
);
?>
<a class="button tips" href="<?php echo esc_url( $download_url ); ?>"
data-tip="<?php esc_attr_e( 'Download Invoice', 'wp-bnb' ); ?>">
<span class="dashicons dashicons-pdf" style="vertical-align: middle;"></span>
</a>
<?php
}
/**
* AJAX handler for generating invoice.
*
* @return void
*/
public static function ajax_generate_invoice(): void {
check_ajax_referer( 'wp_bnb_admin_nonce', 'nonce' );
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-bnb' ) ) );
}
$order_id = isset( $_POST['order_id'] ) ? absint( $_POST['order_id'] ) : 0;
if ( ! $order_id ) {
wp_send_json_error( array( 'message' => __( 'Invalid order ID.', 'wp-bnb' ) ) );
}
$order = wc_get_order( $order_id );
if ( ! $order ) {
wp_send_json_error( array( 'message' => __( 'Order not found.', 'wp-bnb' ) ) );
}
$file_path = self::generate_invoice( $order );
if ( $file_path ) {
wp_send_json_success( array( 'message' => __( 'Invoice generated successfully.', 'wp-bnb' ) ) );
} else {
wp_send_json_error( array( 'message' => __( 'Failed to generate invoice.', 'wp-bnb' ) ) );
}
}
/**
* Handle invoice download request.
*
* @return void
*/
public static function handle_invoice_download(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['bnb_download_invoice'] ) ) {
return;
}
$order_id = absint( $_GET['bnb_download_invoice'] );
// Verify nonce.
if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_key( $_GET['_wpnonce'] ), 'bnb_download_invoice_' . $order_id ) ) {
wp_die( esc_html__( 'Security check failed.', 'wp-bnb' ) );
}
// Check capabilities.
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'You do not have permission to download invoices.', 'wp-bnb' ) );
}
$order = wc_get_order( $order_id );
if ( ! $order ) {
wp_die( esc_html__( 'Order not found.', 'wp-bnb' ) );
}
// Generate invoice if needed.
if ( ! self::invoice_exists( $order ) ) {
self::generate_invoice( $order );
}
$invoice_path = $order->get_meta( '_bnb_invoice_path', true );
if ( ! $invoice_path || ! file_exists( $invoice_path ) ) {
wp_die( esc_html__( 'Invoice not found.', 'wp-bnb' ) );
}
$invoice_number = $order->get_meta( '_bnb_invoice_number', true );
$filename = 'invoice-' . sanitize_file_name( $invoice_number ) . '.pdf';
// Output file.
header( 'Content-Type: application/pdf' );
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
header( 'Content-Length: ' . filesize( $invoice_path ) );
header( 'Cache-Control: no-cache, no-store, must-revalidate' );
readfile( $invoice_path );
exit;
}
/**
* Get invoice HTML content.
*
* @param \WC_Order $order WooCommerce order.
* @param string $invoice_number Invoice number.
* @param int|null $booking_id Booking ID.
* @return string HTML content.
*/
private static function get_invoice_html( \WC_Order $order, string $invoice_number, ?int $booking_id ): string {
// Get business info.
$business = self::get_business_info();
// Get booking details.
$check_in = '';
$check_out = '';
$room_name = '';
$building_name = '';
$nights = 0;
$guests = 0;
if ( $booking_id ) {
$check_in = get_post_meta( $booking_id, '_bnb_booking_check_in', true );
$check_out = get_post_meta( $booking_id, '_bnb_booking_check_out', true );
$guests = get_post_meta( $booking_id, '_bnb_booking_adults', true );
$room_id = get_post_meta( $booking_id, '_bnb_booking_room_id', true );
if ( $room_id ) {
$room = get_post( $room_id );
$room_name = $room ? $room->post_title : '';
$building = Room::get_building( $room_id );
$building_name = $building ? $building->post_title : '';
}
if ( $check_in && $check_out ) {
$check_in_date = \DateTime::createFromFormat( 'Y-m-d', $check_in );
$check_out_date = \DateTime::createFromFormat( 'Y-m-d', $check_out );
if ( $check_in_date && $check_out_date ) {
$nights = $check_in_date->diff( $check_out_date )->days;
}
}
}
// Format dates.
$date_format = get_option( 'date_format' );
$check_in_display = $check_in ? date_i18n( $date_format, strtotime( $check_in ) ) : '';
$check_out_display = $check_out ? date_i18n( $date_format, strtotime( $check_out ) ) : '';
$order_date = $order->get_date_created() ? $order->get_date_created()->date_i18n( $date_format ) : '';
// Get logo.
$logo_html = '';
$logo_id = Manager::get_invoice_logo();
if ( $logo_id ) {
$logo_url = wp_get_attachment_url( $logo_id );
if ( $logo_url ) {
$logo_html = '<img src="' . esc_url( $logo_url ) . '" style="max-height: 60px; max-width: 200px;">';
}
}
// Get footer.
$footer_text = Manager::get_invoice_footer();
// Build HTML.
$html = '<html><head><style>' . self::get_invoice_css() . '</style></head><body>';
// Header.
$html .= '<div class="invoice-header">';
$html .= '<div class="logo">' . $logo_html . '</div>';
$html .= '<div class="invoice-title">';
$html .= '<h1>' . esc_html__( 'INVOICE', 'wp-bnb' ) . '</h1>';
$html .= '<p class="invoice-number">' . esc_html( $invoice_number ) . '</p>';
$html .= '<p class="invoice-date">' . esc_html( $order_date ) . '</p>';
$html .= '</div>';
$html .= '</div>';
// Addresses.
$html .= '<div class="addresses">';
// From.
$html .= '<div class="address from">';
$html .= '<h3>' . esc_html__( 'From', 'wp-bnb' ) . '</h3>';
$html .= '<p><strong>' . esc_html( $business['name'] ) . '</strong></p>';
if ( $business['street'] ) {
$html .= '<p>' . esc_html( $business['street'] ) . '</p>';
}
if ( $business['city'] || $business['postal'] ) {
$html .= '<p>' . esc_html( $business['postal'] . ' ' . $business['city'] ) . '</p>';
}
if ( $business['country'] ) {
$html .= '<p>' . esc_html( $business['country'] ) . '</p>';
}
if ( $business['email'] ) {
$html .= '<p>' . esc_html( $business['email'] ) . '</p>';
}
if ( $business['phone'] ) {
$html .= '<p>' . esc_html( $business['phone'] ) . '</p>';
}
$html .= '</div>';
// To.
$html .= '<div class="address to">';
$html .= '<h3>' . esc_html__( 'Bill To', 'wp-bnb' ) . '</h3>';
$html .= '<p><strong>' . esc_html( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() ) . '</strong></p>';
if ( $order->get_billing_address_1() ) {
$html .= '<p>' . esc_html( $order->get_billing_address_1() ) . '</p>';
}
if ( $order->get_billing_city() || $order->get_billing_postcode() ) {
$html .= '<p>' . esc_html( $order->get_billing_postcode() . ' ' . $order->get_billing_city() ) . '</p>';
}
if ( $order->get_billing_country() ) {
$html .= '<p>' . esc_html( WC()->countries->countries[ $order->get_billing_country() ] ?? $order->get_billing_country() ) . '</p>';
}
if ( $order->get_billing_email() ) {
$html .= '<p>' . esc_html( $order->get_billing_email() ) . '</p>';
}
$html .= '</div>';
$html .= '</div>';
// Booking details.
if ( $booking_id && $room_name ) {
$html .= '<div class="booking-details">';
$html .= '<h3>' . esc_html__( 'Booking Details', 'wp-bnb' ) . '</h3>';
$html .= '<table class="details-table">';
$html .= '<tr><td><strong>' . esc_html__( 'Room', 'wp-bnb' ) . '</strong></td><td>' . esc_html( $room_name );
if ( $building_name ) {
$html .= ' <small>(' . esc_html( $building_name ) . ')</small>';
}
$html .= '</td></tr>';
$html .= '<tr><td><strong>' . esc_html__( 'Check-in', 'wp-bnb' ) . '</strong></td><td>' . esc_html( $check_in_display ) . '</td></tr>';
$html .= '<tr><td><strong>' . esc_html__( 'Check-out', 'wp-bnb' ) . '</strong></td><td>' . esc_html( $check_out_display ) . '</td></tr>';
$html .= '<tr><td><strong>' . esc_html__( 'Nights', 'wp-bnb' ) . '</strong></td><td>' . esc_html( $nights ) . '</td></tr>';
$html .= '<tr><td><strong>' . esc_html__( 'Guests', 'wp-bnb' ) . '</strong></td><td>' . esc_html( $guests ) . '</td></tr>';
$html .= '</table>';
$html .= '</div>';
}
// Line items.
$html .= '<div class="line-items">';
$html .= '<table class="items-table">';
$html .= '<thead><tr>';
$html .= '<th class="description">' . esc_html__( 'Description', 'wp-bnb' ) . '</th>';
$html .= '<th class="qty">' . esc_html__( 'Qty', 'wp-bnb' ) . '</th>';
$html .= '<th class="price">' . esc_html__( 'Price', 'wp-bnb' ) . '</th>';
$html .= '<th class="total">' . esc_html__( 'Total', 'wp-bnb' ) . '</th>';
$html .= '</tr></thead>';
$html .= '<tbody>';
foreach ( $order->get_items() as $item ) {
$qty = $item->get_quantity();
$total = $item->get_total();
$price = $qty > 0 ? $total / $qty : $total;
$html .= '<tr>';
$html .= '<td class="description">' . esc_html( $item->get_name() ) . '</td>';
$html .= '<td class="qty">' . esc_html( $qty ) . '</td>';
$html .= '<td class="price">' . wc_price( $price ) . '</td>';
$html .= '<td class="total">' . wc_price( $total ) . '</td>';
$html .= '</tr>';
}
$html .= '</tbody>';
$html .= '</table>';
$html .= '</div>';
// Totals.
$html .= '<div class="totals">';
$html .= '<table class="totals-table">';
$html .= '<tr><td>' . esc_html__( 'Subtotal', 'wp-bnb' ) . '</td><td>' . wc_price( $order->get_subtotal() ) . '</td></tr>';
if ( $order->get_total_tax() > 0 ) {
$html .= '<tr><td>' . esc_html__( 'Tax', 'wp-bnb' ) . '</td><td>' . wc_price( $order->get_total_tax() ) . '</td></tr>';
}
$html .= '<tr class="grand-total"><td><strong>' . esc_html__( 'Total', 'wp-bnb' ) . '</strong></td><td><strong>' . wc_price( $order->get_total() ) . '</strong></td></tr>';
$html .= '</table>';
$html .= '</div>';
// Payment info.
$html .= '<div class="payment-info">';
$html .= '<p><strong>' . esc_html__( 'Payment Status:', 'wp-bnb' ) . '</strong> ';
if ( $order->is_paid() ) {
$html .= '<span class="paid">' . esc_html__( 'PAID', 'wp-bnb' ) . '</span>';
} else {
$html .= '<span class="unpaid">' . esc_html__( 'PENDING', 'wp-bnb' ) . '</span>';
}
$html .= '</p>';
$html .= '<p><strong>' . esc_html__( 'Payment Method:', 'wp-bnb' ) . '</strong> ' . esc_html( $order->get_payment_method_title() ) . '</p>';
$html .= '</div>';
// Footer.
$html .= '<div class="footer">';
$html .= '<p>' . esc_html__( 'Thank you for your stay!', 'wp-bnb' ) . '</p>';
if ( $footer_text ) {
$html .= '<p class="custom-footer">' . esc_html( $footer_text ) . '</p>';
}
$html .= '</div>';
$html .= '</body></html>';
/**
* Filter the invoice HTML.
*
* @param string $html Invoice HTML.
* @param \WC_Order $order WooCommerce order.
*/
return apply_filters( 'wp_bnb_wc_invoice_html', $html, $order );
}
/**
* Get invoice CSS styles.
*
* @return string CSS content.
*/
private static function get_invoice_css(): string {
return '
body { font-family: DejaVu Sans, sans-serif; font-size: 10pt; color: #333; line-height: 1.4; }
h1 { font-size: 24pt; color: #2271b1; margin: 0; text-align: right; }
h3 { font-size: 11pt; color: #50575e; margin: 15pt 0 5pt 0; }
.invoice-header { margin-bottom: 30pt; overflow: hidden; }
.logo { float: left; width: 50%; }
.invoice-title { float: right; width: 50%; text-align: right; }
.invoice-number { font-size: 14pt; font-weight: bold; color: #333; margin: 5pt 0; }
.invoice-date { font-size: 10pt; color: #787c82; margin: 0; }
.addresses { margin-bottom: 20pt; overflow: hidden; }
.address { width: 48%; }
.address.from { float: left; }
.address.to { float: right; }
.address p { margin: 2pt 0; font-size: 9pt; }
.booking-details { margin-bottom: 20pt; background: #f9f9f9; padding: 10pt; border-radius: 4pt; }
.details-table { width: 100%; border-collapse: collapse; }
.details-table td { padding: 4pt 8pt; font-size: 9pt; border-bottom: 1px solid #e1e4e8; }
.details-table td:first-child { width: 120pt; }
.line-items { margin-bottom: 20pt; }
.items-table { width: 100%; border-collapse: collapse; }
.items-table th { background: #f6f7f7; text-align: left; padding: 8pt; font-size: 9pt; border-bottom: 2px solid #c3c4c7; }
.items-table td { padding: 8pt; font-size: 9pt; border-bottom: 1px solid #e1e4e8; }
.items-table .qty, .items-table .price, .items-table .total { text-align: right; width: 70pt; }
.totals { margin-bottom: 20pt; }
.totals-table { width: 250pt; margin-left: auto; border-collapse: collapse; }
.totals-table td { padding: 6pt 8pt; font-size: 10pt; }
.totals-table td:last-child { text-align: right; }
.totals-table .grand-total td { border-top: 2px solid #333; font-size: 12pt; }
.payment-info { margin-bottom: 30pt; padding: 10pt; background: #f9f9f9; border-radius: 4pt; }
.payment-info p { margin: 4pt 0; font-size: 9pt; }
.payment-info .paid { color: #00a32a; font-weight: bold; }
.payment-info .unpaid { color: #dba617; font-weight: bold; }
.footer { text-align: center; margin-top: 40pt; padding-top: 15pt; border-top: 1px solid #c3c4c7; }
.footer p { font-size: 9pt; color: #787c82; margin: 3pt 0; }
.custom-footer { font-size: 8pt; }
';
}
/**
* Get business information from settings.
*
* @return array Business info.
*/
private static function get_business_info(): array {
return array(
'name' => get_option( 'wp_bnb_business_name', get_bloginfo( 'name' ) ),
'street' => get_option( 'wp_bnb_address_street', '' ),
'city' => get_option( 'wp_bnb_address_city', '' ),
'postal' => get_option( 'wp_bnb_address_postal', '' ),
'country' => get_option( 'wp_bnb_address_country', '' ),
'email' => get_option( 'wp_bnb_contact_email', get_option( 'admin_email' ) ),
'phone' => get_option( 'wp_bnb_contact_phone', '' ),
);
}
}