| @@ -18,8 +18,77 @@ | ||
| 18 | 18 | |
| 19 | 19 | class TemplateTextHelper { |
| 20 | 20 | |
| 21 | 21 | /** |
| 22 | + * The footer printed under a document: the document's own footer text, | |
| 23 | + * else the default from Settings, then the `easy_invoice_pdf_footer_html` | |
| 24 | + * filter (Pro's White-Label addon replaces it there). | |
| 25 | + * | |
| 26 | + * @param object $document Invoice or Quote model. | |
| 27 | + * @param string $type 'invoice' or 'quote'. | |
| 28 | + */ | |
| 29 | + public static function footerText($document, string $type = 'invoice'): string { | |
| 30 | + $text = is_callable([$document, 'getFooterText']) ? (string) $document->getFooterText() : ''; | |
| 31 | + if ('' === trim($text)) { | |
| 32 | + $text = 'quote' === $type | |
| 33 | + ? \EasyInvoice\Controllers\SettingsController::getQuoteFooterText() | |
| 34 | + : \EasyInvoice\Controllers\SettingsController::getInvoiceFooterText(); | |
| 35 | + } | |
| 36 | + /** | |
| 37 | + * Filter the footer of a rendered document (page, PDF, email attachment). | |
| 38 | + * | |
| 39 | + * @param string $text Footer text or HTML. | |
| 40 | + * @param object $document The document. | |
| 41 | + * @param string $type 'invoice' or 'quote'. | |
| 42 | + */ | |
| 43 | + return (string) apply_filters('easy_invoice_pdf_footer_html', $text, $document, $type); | |
| 44 | + } | |
| 45 | + | |
| 46 | + /** | |
| 47 | + * Is this document a credit note? Credit notes are stored as their own | |
| 48 | + * post type but render through the invoice designs. | |
| 49 | + * | |
| 50 | + * @param object $document Invoice model. | |
| 51 | + */ | |
| 52 | + public static function isCreditNote($document): bool { | |
| 53 | + return is_object($document) && is_callable([$document, 'getId']) | |
| 54 | + && get_post_type((int) $document->getId()) === \EasyInvoice\Constants\PostTypes::EASY_INVOICE_CREDIT_NOTE_POST_TYPE; | |
| 55 | + } | |
| 56 | + | |
| 57 | + /** | |
| 58 | + * The labels a design should use for this particular document: the | |
| 59 | + * invoice labels as saved, or the credit-note wording when the document | |
| 60 | + * is a credit note (a credit note is not a bill — no due date, no amount | |
| 61 | + * to pay, no payment terms). | |
| 62 | + * | |
| 63 | + * @param array $text_settings From getInvoiceTextSettings(). | |
| 64 | + * @param object $document Invoice model. | |
| 65 | + * @return array | |
| 66 | + */ | |
| 67 | + public static function forDocument(array $text_settings, $document): array { | |
| 68 | + if (!self::isCreditNote($document)) { | |
| 69 | + return $text_settings; | |
| 70 | + } | |
| 71 | + $text_settings['invoice'] = __('Credit Note', 'easy-invoice'); | |
| 72 | + $text_settings['invoice_number'] = __('Credit Note Number', 'easy-invoice'); | |
| 73 | + $text_settings['invoice_date'] = __('Credit Note Date', 'easy-invoice'); | |
| 74 | + $text_settings['total'] = __('Total Credited', 'easy-invoice'); | |
| 75 | + $text_settings['total_due'] = __('Total Credited', 'easy-invoice'); | |
| 76 | + return $text_settings; | |
| 77 | + } | |
| 78 | + | |
| 79 | + /** | |
| 80 | + * The document kind printed above the title. | |
| 81 | + * | |
| 82 | + * @param object $document Invoice model. | |
| 83 | + */ | |
| 84 | + public static function documentKind($document): string { | |
| 85 | + return self::isCreditNote($document) | |
| 86 | + ? __('Credit Note', 'easy-invoice') | |
| 87 | + : \EasyInvoice\Controllers\SettingsController::getTextInvoice(); | |
| 88 | + } | |
| 89 | + | |
| 90 | + /** | |
| 22 | 91 | * Get all text settings for templates |
| 23 | 92 | * |
| 24 | 93 | * @return array Array of text settings |
| 25 | 94 | */ |
| @@ -43,8 +112,9 @@ | ||
| 43 | 112 | 'rate_price' => \EasyInvoice\Controllers\SettingsController::getTextRatePrice(), |
| 44 | 113 | 'adjust' => \EasyInvoice\Controllers\SettingsController::getTextAdjust(), |
| 45 | 114 | 'sub_total' => \EasyInvoice\Controllers\SettingsController::getTextSubTotal(), |
| 46 | 115 | 'total' => \EasyInvoice\Controllers\SettingsController::getTextTotal(), |
| 116 | + 'line_total' => \EasyInvoice\Controllers\SettingsController::getTextLineTotal(), | |
| 47 | 117 | 'tax' => \EasyInvoice\Controllers\SettingsController::getTextTax(), |
| 48 | 118 | 'discount' => \EasyInvoice\Controllers\SettingsController::getTextDiscount(), |
| 49 | 119 | |
| 50 | 120 | // Button labels |
| @@ -63,9 +133,9 @@ | ||
| 63 | 133 | * |
| 64 | 134 | * @return array Company information |
| 65 | 135 | */ |
| 66 | 136 | public static function getCompanyInfo(): array { |
| 67 | - return [ | |
| 137 | + $info = [ | |
| 68 | 138 | 'name' => get_option('easy_invoice_company_name', 'Your Company Name'), |
| 69 | 139 | 'address' => get_option('easy_invoice_company_address', ''), |
| 70 | 140 | 'email' => get_option('easy_invoice_company_email', ''), |
| 71 | 141 | 'phone' => get_option('easy_invoice_company_phone', ''), |
| @@ -70,10 +140,19 @@ | ||
| 70 | 140 | 'email' => get_option('easy_invoice_company_email', ''), |
| 71 | 141 | 'phone' => get_option('easy_invoice_company_phone', ''), |
| 72 | 142 | 'website' => get_option('easy_invoice_company_website', ''), |
| 73 | 143 | 'logo' => get_option('easy_invoice_company_logo', ''), |
| 74 | - 'tax_number' => get_option('easy_invoice_tax_number', ''), | |
| 144 | + 'tax_number' => get_option('easy_invoice_tax_number', '') ?: get_option('easy_invoice_company_vat_number', ''), | |
| 75 | 145 | ]; |
| 146 | + /** | |
| 147 | + * Filter the resolved company info used by invoice/quote templates | |
| 148 | + * and PDF generation. The White-Label addon hooks this to replace | |
| 149 | + * the logo with its branded header image without forcing every | |
| 150 | + * admin to also see the white-label logo on their settings page. | |
| 151 | + * | |
| 152 | + * @param array<string,string> $info Resolved company info. | |
| 153 | + */ | |
| 154 | + return apply_filters('easy_invoice_template_company_info', $info); | |
| 76 | 155 | } |
| 77 | 156 | |
| 78 | 157 | /** |
| 79 | 158 | * Get invoice specific text settings |
| @@ -92,8 +171,9 @@ | ||
| 92 | 171 | 'rate_price' => \EasyInvoice\Controllers\SettingsController::getTextRatePrice(), |
| 93 | 172 | 'adjust' => \EasyInvoice\Controllers\SettingsController::getTextAdjust(), |
| 94 | 173 | 'sub_total' => \EasyInvoice\Controllers\SettingsController::getTextSubTotal(), |
| 95 | 174 | 'total' => \EasyInvoice\Controllers\SettingsController::getTextTotal(), |
| 175 | + 'line_total' => \EasyInvoice\Controllers\SettingsController::getTextLineTotal(), | |
| 96 | 176 | 'tax' => \EasyInvoice\Controllers\SettingsController::getTextTax(), |
| 97 | 177 | 'discount' => \EasyInvoice\Controllers\SettingsController::getTextDiscount(), |
| 98 | 178 | 'print' => \EasyInvoice\Controllers\SettingsController::getTextPrint(), |
| 99 | 179 | 'download_pdf' => \EasyInvoice\Controllers\SettingsController::getTextDownloadPdf(), |
| @@ -118,8 +198,9 @@ | ||
| 118 | 198 | 'rate_price' => \EasyInvoice\Controllers\SettingsController::getTextRatePrice(), |
| 119 | 199 | 'adjust' => \EasyInvoice\Controllers\SettingsController::getTextAdjust(), |
| 120 | 200 | 'sub_total' => \EasyInvoice\Controllers\SettingsController::getTextSubTotal(), |
| 121 | 201 | 'total' => \EasyInvoice\Controllers\SettingsController::getTextTotal(), |
| 202 | + 'line_total' => \EasyInvoice\Controllers\SettingsController::getTextLineTotal(), | |
| 122 | 203 | 'tax' => \EasyInvoice\Controllers\SettingsController::getTextTax(), |
| 123 | 204 | 'discount' => \EasyInvoice\Controllers\SettingsController::getTextDiscount(), |
| 124 | 205 | 'print' => \EasyInvoice\Controllers\SettingsController::getTextPrint(), |
| 125 | 206 | 'download_pdf' => \EasyInvoice\Controllers\SettingsController::getTextDownloadPdf(), |
| @@ -175,8 +256,13 @@ | ||
| 175 | 256 | } |
| 176 | 257 | if ($company_info['website']) { |
| 177 | 258 | $output .= '<div class="company-website">' . esc_html($company_info['website']) . '</div>'; |
| 178 | 259 | } |
| 260 | + if (!empty($company_info['tax_number'])) { | |
| 261 | + // A tax identifier belongs on the document itself; EU B2B | |
| 262 | + // invoices are not valid without the supplier's VAT ID. | |
| 263 | + $output .= '<div class="company-tax-number">' . esc_html(self::taxIdLabel()) . ' ' . esc_html($company_info['tax_number']) . '</div>'; | |
| 264 | + } | |
| 179 | 265 | $output .= '</div>'; |
| 180 | 266 | break; |
| 181 | 267 | |
| 182 | 268 | case 'elegant': |
| @@ -303,8 +389,107 @@ | ||
| 303 | 389 | return $output; |
| 304 | 390 | } |
| 305 | 391 | |
| 306 | 392 | /** |
| 393 | + * What is still owed on an invoice: the total less completed payments. | |
| 394 | + * | |
| 395 | + * @param mixed $invoice Invoice model. | |
| 396 | + * @return float | |
| 397 | + */ | |
| 398 | + public static function amountDue($invoice): float { | |
| 399 | + // Total less completed payments and credit notes; see InvoiceBalance | |
| 400 | + // for the `easy_invoice_amount_due` filter. | |
| 401 | + return \EasyInvoice\Services\InvoiceBalance::due($invoice); | |
| 402 | + } | |
| 403 | + | |
| 404 | + /** | |
| 405 | + * Label in front of a tax identifier, on both sides of the document. | |
| 406 | + * | |
| 407 | + * @return string | |
| 408 | + */ | |
| 409 | + public static function taxIdLabel(): string { | |
| 410 | + /** | |
| 411 | + * Filter the label shown before tax identifiers on documents. | |
| 412 | + * | |
| 413 | + * @param string $label Label, "VAT" by default. | |
| 414 | + */ | |
| 415 | + return (string) apply_filters('easy_invoice_tax_id_label', __('VAT', 'easy-invoice')); | |
| 416 | + } | |
| 417 | + | |
| 418 | + /** | |
| 419 | + * The recipient block of a document: who it is for and how to reach them. | |
| 420 | + * | |
| 421 | + * Business name first, the contact person underneath when the client | |
| 422 | + * record has both, then address, email, phone and the client's VAT | |
| 423 | + * number. Returns '' when there is nobody to address, so a design can | |
| 424 | + * leave the block out instead of printing an empty "To". | |
| 425 | + * | |
| 426 | + * @param mixed $document Invoice or Quote model. | |
| 427 | + * @return string HTML, or '' when the document has no recipient. | |
| 428 | + */ | |
| 429 | + public static function generateClientBlock($document): string { | |
| 430 | + if (!is_object($document)) { | |
| 431 | + return ''; | |
| 432 | + } | |
| 433 | + $name = trim((string) (is_callable([$document, 'getCustomerName']) ? $document->getCustomerName() : '')); | |
| 434 | + $address = trim((string) (is_callable([$document, 'getCustomerAddress']) ? $document->getCustomerAddress() : '')); | |
| 435 | + $email = trim((string) (is_callable([$document, 'getCustomerEmail']) ? $document->getCustomerEmail() : '')); | |
| 436 | + $vat = trim((string) (is_callable([$document, 'getCustomerVatNumber']) ? $document->getCustomerVatNumber() : '')); | |
| 437 | + $contact = ''; | |
| 438 | + $phone = ''; | |
| 439 | + | |
| 440 | + $client_id = is_callable([$document, 'getClientId']) ? (int) $document->getClientId() : 0; | |
| 441 | + if ($client_id > 0) { | |
| 442 | + $client = (new \EasyInvoice\Repositories\ClientRepository())->find($client_id); | |
| 443 | + if ($client) { | |
| 444 | + $person = trim((string) $client->getFirstName() . ' ' . (string) $client->getLastName()); | |
| 445 | + if ('' !== $person && '' !== $name && 0 !== strcasecmp($person, $name)) { | |
| 446 | + $contact = $person; | |
| 447 | + } | |
| 448 | + $phone = trim((string) $client->getPhone()); | |
| 449 | + if ('' === $address) { | |
| 450 | + $address = trim((string) $client->getAddress()); | |
| 451 | + } | |
| 452 | + if ('' === $email) { | |
| 453 | + $email = trim((string) $client->getEmail()); | |
| 454 | + } | |
| 455 | + } | |
| 456 | + } | |
| 457 | + | |
| 458 | + if ('' === $name . $address . $email . $phone . $vat) { | |
| 459 | + return ''; | |
| 460 | + } | |
| 461 | + | |
| 462 | + $output = ''; | |
| 463 | + if ('' !== $name) { | |
| 464 | + $output .= '<div class="client-name">' . esc_html($name) . '</div>'; | |
| 465 | + } | |
| 466 | + if ('' !== $contact) { | |
| 467 | + $output .= '<div class="client-contact">' . esc_html($contact) . '</div>'; | |
| 468 | + } | |
| 469 | + if ('' !== $address) { | |
| 470 | + $output .= '<div class="client-address">' . nl2br(esc_html($address)) . '</div>'; | |
| 471 | + } | |
| 472 | + if ('' !== $email) { | |
| 473 | + $output .= '<div class="client-email">' . esc_html($email) . '</div>'; | |
| 474 | + } | |
| 475 | + if ('' !== $phone) { | |
| 476 | + $output .= '<div class="client-phone">' . esc_html($phone) . '</div>'; | |
| 477 | + } | |
| 478 | + if ('' !== $vat) { | |
| 479 | + $output .= '<div class="client-tax-number">' . esc_html(self::taxIdLabel()) . ' ' . esc_html($vat) . '</div>'; | |
| 480 | + } | |
| 481 | + | |
| 482 | + /** | |
| 483 | + * Filter the recipient block rendered by every document design. | |
| 484 | + * | |
| 485 | + * @param string $output HTML lines. | |
| 486 | + * @param mixed $document Invoice or Quote model. | |
| 487 | + */ | |
| 488 | + return (string) apply_filters('easy_invoice_client_block_html', $output, $document); | |
| 489 | + } | |
| 490 | + | |
| 491 | + /** | |
| 307 | 492 | * Generate totals section for invoice/quote templates |
| 308 | 493 | * |
| 309 | 494 | * @param mixed $document Invoice or Quote object |
| 310 | 495 | * @param array $text_settings Text settings array |
| @@ -316,35 +501,44 @@ | ||
| 316 | 501 | $output = ''; |
| 317 | 502 | |
| 318 | 503 | // Subtotal |
| 319 | 504 | $output .= '<div class="' . $type . '-total-row">'; |
| 320 | - $output .= '<span class="' . $type . '-total-label">' . esc_html($text_settings['sub_total']) . ':</span>'; | |
| 505 | + $output .= '<span class="' . $type . '-total-label">' . esc_html($text_settings['sub_total']) . '</span>'; | |
| 321 | 506 | $output .= '<span class="' . $type . '-total-value">' . esc_html($formatter->format($document->getSubtotal())) . '</span>'; |
| 322 | 507 | $output .= '</div>'; |
| 323 | 508 | |
| 324 | - // Discount (if any) | |
| 509 | + $discount_row = ''; | |
| 325 | 510 | if ($document->getDiscountAmount() > 0) { |
| 326 | - $output .= '<div class="' . $type . '-total-row">'; | |
| 327 | - $output .= '<span class="' . $type . '-total-label">' . esc_html($text_settings['discount']); | |
| 511 | + $discount_row .= '<div class="' . $type . '-total-row">'; | |
| 512 | + $discount_row .= '<span class="' . $type . '-total-label">' . esc_html($text_settings['discount']); | |
| 328 | 513 | if ($document->getDiscountType() === 'percentage') { |
| 329 | - $output .= ' (' . esc_html($document->getDiscountValue()) . '%)'; | |
| 514 | + $discount_row .= ' (' . esc_html($document->getDiscountValue()) . '%)'; | |
| 330 | 515 | } |
| 331 | - $output .= ':</span>'; | |
| 332 | - $output .= '<span class="' . $type . '-total-value">-' . esc_html($formatter->format($document->getDiscountAmount())) . '</span>'; | |
| 333 | - $output .= '</div>'; | |
| 516 | + $discount_row .= '</span>'; | |
| 517 | + $discount_row .= '<span class="' . $type . '-total-value">-' . esc_html($formatter->format($document->getDiscountAmount())) . '</span>'; | |
| 518 | + $discount_row .= '</div>'; | |
| 334 | 519 | } |
| 335 | - | |
| 336 | - | |
| 337 | - // Tax (if any) | |
| 520 | + | |
| 521 | + $tax_row = ''; | |
| 338 | 522 | if ($document->getTaxAmount() > 0) { |
| 339 | - $output .= '<div class="' . $type . '-total-row">'; | |
| 340 | - $output .= '<span class="' . $type . '-total-label">' . esc_html(easy_invoice_get_tax_name()); | |
| 341 | - $output .= ' (' . esc_html($document->getTaxRate()) . '%):</span>'; | |
| 342 | - $output .= '<span class="' . $type . '-total-value">' . esc_html($formatter->format($document->getTaxAmount())) . '</span>'; | |
| 343 | - $output .= '</div>'; | |
| 523 | + $tax_row .= '<div class="' . $type . '-total-row">'; | |
| 524 | + $tax_row .= '<span class="' . $type . '-total-label">' . esc_html(easy_invoice_get_tax_name()); | |
| 525 | + $tax_row .= ' (' . esc_html($document->getTaxRate()) . '%)</span>'; | |
| 526 | + $tax_row .= '<span class="' . $type . '-total-value">' . esc_html($formatter->format($document->getTaxAmount())) . '</span>'; | |
| 527 | + $tax_row .= '</div>'; | |
| 344 | 528 | } |
| 529 | + | |
| 530 | + // The rows read in the order the figures are computed: a discount taken | |
| 531 | + // before tax sits above the tax line; one taken off the taxed total sits | |
| 532 | + // below it. Shown the other way round, the arithmetic does not add up | |
| 533 | + // on the page. | |
| 534 | + // The model treats anything but 'before_tax' as after-tax. | |
| 535 | + $method = (string) ($document->discount_calculation_method ?? ''); | |
| 536 | + $output .= ('before_tax' === $method) ? $discount_row . $tax_row : $tax_row . $discount_row; | |
| 345 | 537 | |
| 346 | - // Allow plugins to add content | |
| 538 | + // Allow plugins to add content. Captured so it lands between the | |
| 539 | + // rows and the grand total rather than printing ahead of the block. | |
| 540 | + ob_start(); | |
| 347 | 541 | if ($type === 'invoice') { |
| 348 | 542 | do_action('easy_invoice_invoice_totals_after_tax', $document); |
| 349 | 543 | do_action('easy_invoice_invoice_totals_after_discount', $document); |
| 350 | 544 | } else { |
| @@ -350,14 +544,58 @@ | ||
| 350 | 544 | } else { |
| 351 | 545 | do_action('easy_invoice_quote_totals_after_tax', $document); |
| 352 | 546 | do_action('easy_invoice_quote_totals_after_discount', $document); |
| 353 | 547 | } |
| 548 | + $output .= (string) ob_get_clean(); | |
| 354 | 549 | |
| 355 | 550 | // Grand Total |
| 356 | 551 | $output .= '<div class="' . $type . '-total-row ' . $type . '-grand-total">'; |
| 357 | - $output .= '<span class="' . $type . '-total-label">' . esc_html($text_settings['total']) . ':</span>'; | |
| 552 | + $output .= '<span class="' . $type . '-total-label">' . esc_html($text_settings['total']) . '</span>'; | |
| 358 | 553 | $output .= '<span class="' . $type . '-total-value">' . esc_html($formatter->format($document->getTotal())) . '</span>'; |
| 359 | 554 | $output .= '</div>'; |
| 360 | - | |
| 555 | + | |
| 556 | + // Money already received: an invoice that is part paid must say what | |
| 557 | + // is still owed, or the reader pays the total again. Skipped when an | |
| 558 | + // addon has printed its own payment breakdown above. | |
| 559 | + if ('invoice' === $type && false === strpos($output, 'partial-payments-breakdown') && is_callable([$document, 'getId'])) { | |
| 560 | + $invoice_id = (int) $document->getId(); | |
| 561 | + $paid = \EasyInvoice\Services\InvoiceBalance::paid($invoice_id); | |
| 562 | + $credited = \EasyInvoice\Services\InvoiceBalance::credited($invoice_id); | |
| 563 | + /** | |
| 564 | + * Filter whether the totals block lists payments received, credit | |
| 565 | + * notes and the balance due. | |
| 566 | + * | |
| 567 | + * @param bool $show Default true when something has been paid or credited. | |
| 568 | + * @param object $document Invoice model. | |
| 569 | + */ | |
| 570 | + if (($paid > 0 || $credited > 0) && apply_filters('easy_invoice_totals_show_payments', true, $document)) { | |
| 571 | + $balance = max(0, (float) $document->getTotal() - $paid - $credited); | |
| 572 | + // A credit note corrects the invoice; the reader needs to see | |
| 573 | + // it counted off before the balance. | |
| 574 | + foreach (\EasyInvoice\Services\CreditNote::forInvoice($invoice_id) as $credit_id) { | |
| 575 | + $credit_amount = (float) get_post_meta($credit_id, '_easy_invoice_total', true); | |
| 576 | + if ($credit_amount <= 0) { | |
| 577 | + continue; | |
| 578 | + } | |
| 579 | + $credit_number = (string) get_post_meta($credit_id, '_easy_invoice_number', true); | |
| 580 | + $output .= '<div class="invoice-total-row invoice-credit-row">'; | |
| 581 | + /* translators: %s: credit note number. */ | |
| 582 | + $output .= '<span class="invoice-total-label">' . esc_html('' !== $credit_number ? sprintf(__('Credit note %s', 'easy-invoice'), $credit_number) : __('Credit note', 'easy-invoice')) . '</span>'; | |
| 583 | + $output .= '<span class="invoice-total-value">-' . esc_html($formatter->format($credit_amount)) . '</span>'; | |
| 584 | + $output .= '</div>'; | |
| 585 | + } | |
| 586 | + if ($paid > 0) { | |
| 587 | + $output .= '<div class="invoice-total-row invoice-paid-row">'; | |
| 588 | + $output .= '<span class="invoice-total-label">' . esc_html__('Paid', 'easy-invoice') . '</span>'; | |
| 589 | + $output .= '<span class="invoice-total-value">-' . esc_html($formatter->format($paid)) . '</span>'; | |
| 590 | + $output .= '</div>'; | |
| 591 | + } | |
| 592 | + $output .= '<div class="invoice-total-row invoice-balance-due">'; | |
| 593 | + $output .= '<span class="invoice-total-label">' . esc_html__('Balance due', 'easy-invoice') . '</span>'; | |
| 594 | + $output .= '<span class="invoice-total-value">' . esc_html($formatter->format($balance)) . '</span>'; | |
| 595 | + $output .= '</div>'; | |
| 596 | + } | |
| 597 | + } | |
| 598 | + | |
| 361 | 599 | return $output; |
| 362 | 600 | } |
| 363 | 601 | } |