From 9821f6259045b4a297c190d2c8e3823602b01765 Mon Sep 17 00:00:00 2001 From: Jose Martinez Date: Sun, 6 Sep 2026 01:53:40 +0200 Subject: [PATCH] Add a read-only CreditNote object for WooCommerce refunds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WooCommerce refunds are invisible to Splash. The connector exposes seven objects and none of them covers a refund, so a remote server sees an order total change but never learns that a credit note exists, who issued it, why, or which invoice it credits. Unlike Invoice, which is a virtual read-only view of an Order, CreditNote is backed by a real WooCommerce entity: the shop_order_refund post type. Its post_parent is the exact counterpart of the fk_facture_source field that the Dolibarr connector's CreditNote object carries. Read-only for now, like Invoice: creating a refund can trigger a gateway refund, so the write path is left out until it is designed explicitly. Notes on two WooCommerce behaviours this had to accommodate: - Signs. WooCommerce exposes the same money twice: get_total() is negative, get_amount() is positive. Totals are built on get_total() so amounts arrive already negated; get_amount() is exposed separately as 'amount'. This is why no equivalent of the Dolibarr connector's CreditModeTrait is needed here. Order\TotalsTrait::toTotalPrice() guards its VAT computation with $totalTaxExcl > 0, which never holds on a credit note and would report every one of them as 0% VAT, so the rate is computed on absolute values instead. - Full versus partial. The _refund_type meta looks like the answer but is not: it is written by WooCommerce Analytics on the woocommerce_order_(partially| fully)_refunded hooks, so it records the state the ORDER reached when the refund landed rather than the refund's own coverage, and it is never revised when a sibling refund is deleted or the order is edited. On a shop of 448 refunds, 165 of 399 carry 'full' while covering part of their order. The object recomputes is_order_fully_refunded live from the parent instead. Line items are the exception rather than the rule — 1 refund in 400 on that same shop has any — so an amount-only refund is rendered as a single line built from its amount and reason, flagged is_virtual_item so a consumer can tell rebuilt detail from real detail. Hooks commit the refund on woocommerce_refund_created and woocommerce_refund_deleted, and re-commit the parent Invoice, whose outstanding amount changed at the same time. --- includes/class-splash-wordpress-plugin.php | 1 + src/Objects/CreditNote.php | 160 ++++++++++ src/Objects/CreditNote/CRUDTrait.php | 141 +++++++++ src/Objects/CreditNote/CoreTrait.php | 333 +++++++++++++++++++++ src/Objects/CreditNote/HooksTrait.php | 133 ++++++++ src/Objects/CreditNote/ItemsTrait.php | 217 ++++++++++++++ src/Objects/CreditNote/ObjectListTrait.php | 141 +++++++++ src/Objects/CreditNote/TotalsTrait.php | 164 ++++++++++ 8 files changed, 1290 insertions(+) create mode 100644 src/Objects/CreditNote.php create mode 100644 src/Objects/CreditNote/CRUDTrait.php create mode 100644 src/Objects/CreditNote/CoreTrait.php create mode 100644 src/Objects/CreditNote/HooksTrait.php create mode 100644 src/Objects/CreditNote/ItemsTrait.php create mode 100644 src/Objects/CreditNote/ObjectListTrait.php create mode 100644 src/Objects/CreditNote/TotalsTrait.php diff --git a/includes/class-splash-wordpress-plugin.php b/includes/class-splash-wordpress-plugin.php index 1d0d4cc..b9b36e9 100644 --- a/includes/class-splash-wordpress-plugin.php +++ b/includes/class-splash-wordpress-plugin.php @@ -171,6 +171,7 @@ public function __construct($file = '', $version = SPLASH_SYNC_VERSION) Splash\Local\Objects\ThirdParty::registerHooks(); Splash\Local\Objects\Product::registerHooks(); Splash\Local\Objects\Order::registerHooks(); + Splash\Local\Objects\CreditNote::registerHooks(); //====================================================================// // Handle User Messages diff --git a/src/Objects/CreditNote.php b/src/Objects/CreditNote.php new file mode 100644 index 0000000..d09d903 --- /dev/null +++ b/src/Objects/CreditNote.php @@ -0,0 +1,160 @@ + + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Splash\Local\Objects; + +use Splash\Models\AbstractObject; +use Splash\Models\Objects; +use WC_Order_Refund; + +/** + * WooCommerce Credit Note Object + * + * Unlike Invoice, which is a virtual read-only view of an Order, a Credit Note is + * backed by a real WooCommerce entity: the `shop_order_refund` post type, handled + * by the WC_Order_Refund class. + * + * This matters for the Dolibarr connector, whose CreditNote object carries a + * `fk_facture_source` pointing back at the invoice being credited. WooCommerce has + * the exact same link natively, as the refund's `post_parent`, so the two sides can + * be mapped without inventing anything. + * + * SIGN CONVENTION + * The Dolibarr connector needs a dedicated `Core\CreditModeTrait` to invert every + * price, because a Dolibarr credit note stores positive amounts. WooCommerce does + * not: `get_total()` already returns a negative value (`get_amount()` is the + * positive one). No inversion trait is needed here — but do not mix the two getters. + * + * SCOPE OF THIS OBJECT + * Read-only for now, exactly like Invoice. Creating a refund from a remote server + * is a money-moving operation — it can trigger a gateway refund — so it is left out + * until the write path has been designed explicitly. + */ +class CreditNote extends AbstractObject +{ + //====================================================================// + // Splash Php Core Traits + //====================================================================// + + use Objects\IntelParserTrait; + use Objects\SimpleFieldsTrait; + use Objects\GenericFieldsTrait; + use Objects\PricesTrait; + use Objects\ListsTrait; + + //====================================================================// + // Core Fields + //====================================================================// + + use Core\WooCommerceObjectTrait; // Trigger WooCommerce Module Activation + + //====================================================================// + // WooCommerce Credit Note Fields + //====================================================================// + + use CreditNote\CRUDTrait; // Objects CRUD + use CreditNote\ObjectListTrait; // Objects Listing + use CreditNote\HooksTrait; // WordPress Hooks + use CreditNote\CoreTrait; // Credit Note Core Infos + use CreditNote\ItemsTrait; // Credit Note Items List + use CreditNote\TotalsTrait; // Credit Note Totals + + //====================================================================// + // Object Definition Parameters + //====================================================================// + + /** + * Object Name (Translated by Module) + * + * {@inheritdoc} + */ + protected static string $name = "Credit Note"; + + /** + * Object Description (Translated by Module) + * + * {@inheritdoc} + */ + protected static string $description = "WooCommerce Order Refund"; + + /** + * Object Icon (FontAwesome or Glyph ico tag) + * + * {@inheritdoc} + */ + protected static string $ico = "fa fa-reply"; + + //====================================================================// + // Object Synchronization Limitations + // + // This Flags are Used by Splash Server to Prevent Unexpected Operations on Remote Server + //====================================================================// + + /** + * {@inheritdoc} + */ + protected static bool $allowPushCreated = false; + + /** + * {@inheritdoc} + */ + protected static bool $allowPushUpdated = false; + + /** + * {@inheritdoc} + */ + protected static bool $allowPushDeleted = false; + + /** + * {@inheritdoc} + */ + protected static bool $enablePushCreated = false; + + /** + * {@inheritdoc} + */ + protected static bool $enablePushUpdated = false; + + /** + * {@inheritdoc} + */ + protected static bool $enablePushDeleted = false; + + //====================================================================// + // General Class Variables + //====================================================================// + + /** + * @var WC_Order_Refund + */ + protected object $object; + + /** + * @var string + */ + protected string $postType = "shop_order_refund"; + + //====================================================================// + // Class Constructor + //====================================================================// + + /** + * Class Constructor + */ + public function __construct() + { + self::setGenericMethodsFormat("snake_case"); + } +} diff --git a/src/Objects/CreditNote/CRUDTrait.php b/src/Objects/CreditNote/CRUDTrait.php new file mode 100644 index 0000000..60178a3 --- /dev/null +++ b/src/Objects/CreditNote/CRUDTrait.php @@ -0,0 +1,141 @@ + + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Splash\Local\Objects\CreditNote; + +use Splash\Core\SplashCore as Splash; +use Splash\Local\Core\PrivacyManager; +use WC_Order; +use WC_Order_Refund; + +/** + * WooCommerce Credit Note CRUD Functions + */ +trait CRUDTrait +{ + /** + * Load Request Object + * + * @param string $postId Object id + * + * @return null|WC_Order_Refund + */ + public function load(string $postId): ?WC_Order_Refund + { + //====================================================================// + // Stack Trace + Splash::log()->trace(); + //====================================================================// + // Init Object + // wc_get_order() returns the right class for any order-like post type, + // so a refund id gives back a WC_Order_Refund. + $wcRefund = wc_get_order((int) $postId); + if (!$wcRefund instanceof WC_Order_Refund) { + return Splash::log()->errNull( + "Unable to load ".$this->postType." (".$postId.")." + ); + } + //====================================================================// + // Check Parent Order Not Anonymized + // A refund carries no personal data of its own: it inherits the customer + // from its parent order, so the parent is what has to be checked. + $parent = $this->getParentOrder($wcRefund); + if ($parent && PrivacyManager::isAnonymize($parent)) { + return Splash::log()->errNull("Reading Anonymized Orders is Forbidden"); + } + + return $wcRefund; + } + + /** + * Create Request Object + * + * Refunds are read-only: creating one is a money-moving operation that may + * trigger a gateway refund, so it is deliberately not exposed. + * + * @return null|WC_Order_Refund + */ + public function create(): ?WC_Order_Refund + { + return Splash::log()->errNull( + "Creating WooCommerce Refunds from Splash is not allowed." + ); + } + + /** + * Update Request Object + * + * @param bool $needed Is This Update Needed + * + * @return null|string Object ID + */ + public function update(bool $needed): ?string + { + //====================================================================// + // Stack Trace + Splash::log()->trace(); + //====================================================================// + // Object is Read-Only: nothing is ever written back. + if ($needed) { + Splash::log()->war("WooCommerce Refunds are Read-Only. Changes were ignored."); + } + + return $this->getObjectIdentifier(); + } + + /** + * Delete Request Object + * + * @param string $postId Object id + * + * @return bool + */ + public function delete(string $postId): bool + { + return Splash::log()->warTrace( + "Deleting WooCommerce Refunds from Splash is not allowed. ID ".$postId + ); + } + + /** + * {@inheritdoc} + */ + public function getObjectIdentifier(): ?string + { + $refundId = $this->object->get_id(); + + return empty($refundId) ? null : (string) $refundId; + } + + /** + * Get the Order this Refund belongs to + * + * This is the WooCommerce counterpart of Dolibarr's `fk_facture_source`. + * + * @param WC_Order_Refund $wcRefund + * + * @return null|WC_Order + */ + protected function getParentOrder(WC_Order_Refund $wcRefund): ?WC_Order + { + $parentId = $wcRefund->get_parent_id(); + if (empty($parentId)) { + return null; + } + $parent = wc_get_order($parentId); + + return ($parent instanceof WC_Order) ? $parent : null; + } +} diff --git a/src/Objects/CreditNote/CoreTrait.php b/src/Objects/CreditNote/CoreTrait.php new file mode 100644 index 0000000..c08125e --- /dev/null +++ b/src/Objects/CreditNote/CoreTrait.php @@ -0,0 +1,333 @@ + + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Splash\Local\Objects\CreditNote; + +/** + * WooCommerce Credit Note Core Data Access + */ +trait CoreTrait +{ + //====================================================================// + // Fields Generation Functions + //====================================================================// + + /** + * Build Core Fields using FieldFactory + * + * @return void + */ + protected function buildCoreFields(): void + { + //====================================================================// + // Source Invoice + // + // This is the field the whole object exists for. Dolibarr stores it as + // `fk_facture_source` on the credit note; WooCommerce stores it as the + // refund's post_parent. Mapping them makes a credit note land on the + // right invoice instead of floating free. + $this->fieldsFactory()->create((string) self::objects()->encode("Invoice", SPL_T_ID)) + ->identifier("parent_id") + ->name(__("Invoice")) + ->microData("http://schema.org/Invoice", "referencesOrder") + ->isReadOnly() + ->isRequired() + ; + //====================================================================// + // Source Order + // + // Same identifier, seen as an Order rather than an Invoice, so a remote + // server that syncs Orders but not Invoices still gets the link. + $this->fieldsFactory()->create((string) self::objects()->encode("Order", SPL_T_ID)) + ->identifier("parent_order_id") + ->name(__("Order")) + ->microData("http://schema.org/Order", "orderNumber") + ->isReadOnly() + ; + //====================================================================// + // Customer Object + // + // Read from the parent order: a refund has no customer of its own. + $this->fieldsFactory()->create((string) self::objects()->encode("ThirdParty", SPL_T_ID)) + ->identifier("_customer_id") + ->name(__("Customer")) + ->microData("http://schema.org/Invoice", "customer") + ->isReadOnly() + ; + //====================================================================// + // Reference + // + // Prefer the credit note number issued by YITH WooCommerce PDF Invoices + // when the plugin is in use, so the reference matches the document the + // customer actually received. Fall back to a derived reference otherwise. + $this->fieldsFactory()->create(SPL_T_VARCHAR) + ->identifier("reference") + ->name(__("Reference")) + ->microData("http://schema.org/Invoice", "confirmationNumber") + ->isReadOnly() + ->isListed() + ; + //====================================================================// + // Credit Note Date + $this->fieldsFactory()->create(SPL_T_DATE) + ->identifier("_date_created") + ->name(__("Date")) + ->microData("http://schema.org/Invoice", "paymentDueDate") + ->isReadOnly() + ->isRequired() + ; + //====================================================================// + // Credit Note Created DateTime + $this->fieldsFactory()->create(SPL_T_DATETIME) + ->identifier("_datetime_created") + ->name(__("Creation DateTime")) + ->microData("http://schema.org/DataFeedItem", "dateCreated") + ->isReadOnly() + ->isListed() + ; + //====================================================================// + // Refund Reason + // + // Free text typed by whoever issued the refund. This is what should end + // up in the credit note description on the remote server. + $this->fieldsFactory()->create(SPL_T_VARCHAR) + ->identifier("reason") + ->name(__("Reason for refund")) + ->microData("http://schema.org/Invoice", "description") + ->isReadOnly() + ->isListed() + ; + //====================================================================// + // Is the Source Invoice now Fully Credited + // + // This is what an accounting system actually needs: whether the invoice + // can be closed or still carries a balance. + // + // Deliberately NOT read from the `_refund_type` meta. That meta is written + // by WooCommerce Analytics (Admin\API\Reports\Products\DataStore) on the + // `woocommerce_order_(partially|fully)_refunded` hooks, so it records the + // state the ORDER reached when this refund landed — not this refund's own + // coverage — and it is never revised afterwards. Deleting a sibling refund + // or editing the order leaves it stale: on a real shop, 165 of 399 refunds + // are flagged "full" while covering only part of their order. It is + // recomputed live from the parent instead. + $this->fieldsFactory()->create(SPL_T_BOOL) + ->identifier("is_order_fully_refunded") + ->name(__("Invoice fully credited")) + ->description(__("Computed live from the parent order, all refunds included")) + ->isReadOnly() + ->isListed() + ; + //====================================================================// + // Total Refunded on the Source Invoice + // + // The cumulative amount credited on the parent, this refund included. A + // remote server needs it to reconcile several credit notes against one + // invoice: 17 orders on a real shop carry more than one refund. + $this->fieldsFactory()->create(SPL_T_DOUBLE) + ->identifier("order_total_refunded") + ->name(__("Total refunded on invoice")) + ->isReadOnly() + ; + //====================================================================// + // Refunded via Payment Gateway + // + // True when WooCommerce actually asked the gateway to move the money, + // false when the refund was only recorded in the shop. A remote + // accounting system needs the difference: only the first one has a + // matching bank movement. + $this->fieldsFactory()->create(SPL_T_BOOL) + ->identifier("refunded_payment") + ->name(__("Refunded via payment gateway")) + ->isReadOnly() + ; + //====================================================================// + // Refunded By + // + // The WordPress user who issued the refund. Kept as a plain name rather + // than a ThirdParty id: this is shop staff, not a customer. + $this->fieldsFactory()->create(SPL_T_VARCHAR) + ->identifier("refunded_by") + ->name(__("Refunded by")) + ->microData("http://schema.org/Author", "name") + ->isReadOnly() + ; + //====================================================================// + // Wordpress Blog Name + $this->fieldsFactory()->create(SPL_T_VARCHAR) + ->identifier("blogname") + ->name("Blog Name") + ->microData("http://schema.org/Author", "alternateName") + ->isReadOnly() + ; + } + + //====================================================================// + // Fields Reading Functions + //====================================================================// + + /** + * Read requested Field + * + * @param string $key Input List Key + * @param string $fieldName Field Identifier / Name + * + * @return void + */ + protected function getCoreFields(string $key, string $fieldName): void + { + //====================================================================// + // READ Fields + switch ($fieldName) { + case 'parent_id': + $parentId = $this->object->get_parent_id(); + $this->out[$fieldName] = $parentId + ? self::objects()->encode("Invoice", (string) $parentId) + : null; + + break; + case 'parent_order_id': + $parentId = $this->object->get_parent_id(); + $this->out[$fieldName] = $parentId + ? self::objects()->encode("Order", (string) $parentId) + : null; + + break; + case '_customer_id': + $parent = $this->getParentOrder($this->object); + $customerId = $parent ? $parent->get_customer_id() : 0; + $this->out[$fieldName] = $customerId + ? self::objects()->encode("ThirdParty", (string) $customerId) + : null; + + break; + case 'reference': + $this->out[$fieldName] = $this->getCreditNoteReference(); + + break; + case '_date_created': + $date = $this->object->get_date_created(); + $this->out[$fieldName] = $date ? $date->format(SPL_T_DATECAST) : null; + + break; + case '_datetime_created': + $date = $this->object->get_date_created(); + $this->out[$fieldName] = $date ? $date->format(SPL_T_DATETIMECAST) : null; + + break; + case 'reason': + $this->out[$fieldName] = (string) $this->object->get_reason(); + + break; + case 'is_order_fully_refunded': + $this->out[$fieldName] = $this->isOrderFullyRefunded(); + + break; + case 'order_total_refunded': + $parent = $this->getParentOrder($this->object); + $this->out[$fieldName] = $parent ? (float) $parent->get_total_refunded() : 0.0; + + break; + case 'refunded_payment': + $this->out[$fieldName] = (bool) $this->object->get_refunded_payment(); + + break; + case 'refunded_by': + $this->out[$fieldName] = $this->getRefundAuthorName(); + + break; + case 'blogname': + /** @var null|string $blogName */ + $blogName = get_option("blogname", "WordPress"); + $this->out[$fieldName] = $blogName ?? "WordPress"; + + break; + default: + return; + } + + unset($this->in[$key]); + } + + //====================================================================// + // Private Helpers + //====================================================================// + + /** + * Build the Credit Note Reference + * + * @return string + */ + private function getCreditNoteReference(): string + { + //====================================================================// + // YITH WooCommerce PDF Invoice issues a real credit note number. When it + // is there, it is the reference the customer has on their document, so + // it wins over anything derived. + $ywpiNumber = (string) $this->object->get_meta('_ywpi_credit_note_formatted_number'); + if (!empty($ywpiNumber)) { + return $ywpiNumber; + } + + //====================================================================// + // Otherwise, derive a stable reference from the parent order, so a credit + // note is still recognisable next to the invoice it belongs to. + $parent = $this->getParentOrder($this->object); + $parentRef = $parent ? $parent->get_order_number() : (string) $this->object->get_parent_id(); + + return sprintf("#%s-R%d", $parentRef, $this->object->get_id()); + } + + /** + * Tell whether the Source Invoice is now Fully Credited + * + * Computed from the parent order's cumulative refunds, so it stays true after + * a sibling refund is deleted or the order is edited. + * + * @return bool + */ + private function isOrderFullyRefunded(): bool + { + $parent = $this->getParentOrder($this->object); + if (!$parent) { + return false; + } + $total = (float) $parent->get_total(); + if ($total <= 0.0) { + return false; + } + + //====================================================================// + // Compare on a one cent tolerance: rounding on split refunds otherwise + // leaves an invoice one cent short of closed. + return ((float) $parent->get_total_refunded() + 0.01) >= $total; + } + + /** + * Get the Display Name of the User who issued the Refund + * + * @return string + */ + private function getRefundAuthorName(): string + { + $userId = (int) $this->object->get_refunded_by(); + if (empty($userId)) { + return ""; + } + $user = get_userdata($userId); + + return $user ? (string) $user->display_name : sprintf("#%d", $userId); + } +} diff --git a/src/Objects/CreditNote/HooksTrait.php b/src/Objects/CreditNote/HooksTrait.php new file mode 100644 index 0000000..d018101 --- /dev/null +++ b/src/Objects/CreditNote/HooksTrait.php @@ -0,0 +1,133 @@ + + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Splash\Local\Objects\CreditNote; + +use Splash\Client\Splash; +use Splash\Local\Core\PrivacyManager; +use Splash\Local\Notifier; + +/** + * WooCommerce Credit Note WordPress Hooks + * + * The connector currently learns about a refund only indirectly, through the order + * being saved, which tells a remote server that a total changed but not that a + * credit note exists. These hooks commit the refund itself. + */ +trait HooksTrait +{ + /** + * Splash Credit Note Class Name + * + * @var string + */ + private static string $creditNoteClass = "\\Splash\\Local\\Objects\\CreditNote"; + + /** + * Register Credit Note Hooks + * + * @return void + */ + public static function registerHooks(): void + { + //====================================================================// + // Refund Created + $createdCall = array(self::$creditNoteClass, "created"); + if (is_callable($createdCall)) { + add_action('woocommerce_refund_created', $createdCall, 10, 2); + } + //====================================================================// + // Refund Deleted + $deletedCall = array(self::$creditNoteClass, "deleted"); + if (is_callable($deletedCall)) { + add_action('woocommerce_refund_deleted', $deletedCall, 10, 2); + } + } + + /** + * Commit a newly created Refund + * + * @param int $refundId Refund Post Id + * @param array $args Refund arguments, as passed to wc_create_refund() + * + * @return void + */ + public static function created(int $refundId, array $args = array()): void + { + //====================================================================// + // Stack Trace + Splash::log()->trace(); + if (empty($refundId)) { + return; + } + //====================================================================// + // Check Parent Order Not Anonymized + $parentId = (int) ($args['order_id'] ?? 0); + if ($parentId && PrivacyManager::isAnonymizeById($parentId)) { + Splash::log()->war("Commit is Disabled for Anonymize Orders"); + + return; + } + //====================================================================// + // Prevent Repeated Commit if Needed + if (Splash::object("CreditNote")->isLocked()) { + return; + } + //====================================================================// + // Do Commit + Splash::commit("CreditNote", $refundId, SPL_A_CREATE, "Wordpress", "Wc Refund Created"); + //====================================================================// + // The parent invoice changed too: its outstanding amount is no longer + // the same. Commit it so both documents stay in step. + if ($parentId) { + Splash::commit("Invoice", $parentId, SPL_A_UPDATE, "Wordpress", "Wc Invoice Refunded"); + } + //====================================================================// + // Store User Messages + Notifier::getInstance()->importLog(); + } + + /** + * Commit a deleted Refund + * + * @param int $refundId Refund Post Id + * @param int $orderId Parent Order Id + * + * @return void + */ + public static function deleted(int $refundId, int $orderId): void + { + //====================================================================// + // Stack Trace + Splash::log()->trace(); + if (empty($refundId)) { + return; + } + //====================================================================// + // Prevent Repeated Commit if Needed + if (Splash::object("CreditNote")->isLocked()) { + return; + } + //====================================================================// + // Do Commit + Splash::commit("CreditNote", $refundId, SPL_A_DELETE, "Wordpress", "Wc Refund Deleted"); + if (!empty($orderId)) { + Splash::commit("Invoice", $orderId, SPL_A_UPDATE, "Wordpress", "Wc Invoice Refund Removed"); + } + //====================================================================// + // Store User Messages + Notifier::getInstance()->importLog(); + } +} diff --git a/src/Objects/CreditNote/ItemsTrait.php b/src/Objects/CreditNote/ItemsTrait.php new file mode 100644 index 0000000..79b616f --- /dev/null +++ b/src/Objects/CreditNote/ItemsTrait.php @@ -0,0 +1,217 @@ + + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Splash\Local\Objects\CreditNote; + +use WC_Order_Item; +use WC_Order_Item_Product; + +/** + * WooCommerce Credit Note Items List + * + * A WooCommerce refund can be line-based or amount-only, and amount-only is by far + * the common case: on a real shop of ~450 refunds, 4 carried line items and 444 did + * not. A remote accounting system still needs at least one line to build a document + * from, so when the refund has no items, one is synthesised from the refund amount + * and its reason. `is_virtual_item` tells the two apart, so a consumer can choose to + * trust the detail or not. + */ +trait ItemsTrait +{ + //====================================================================// + // Fields Generation Functions + //====================================================================// + + /** + * Build Items Fields using FieldFactory + * + * @return void + */ + protected function buildItemsFields(): void + { + $groupName = __("Items"); + + //====================================================================// + // Credit Note Line Description + $this->fieldsFactory()->create(SPL_T_VARCHAR) + ->identifier("name") + ->inList("items") + ->name(__("Item")) + ->group($groupName) + ->microData("http://schema.org/partOfInvoice", "description") + ->association("name@items", "quantity@items", "subtotal@items") + ->isReadOnly() + ; + //====================================================================// + // Credit Note Line Product Identifier + $this->fieldsFactory()->create((string) self::objects()->encode("Product", SPL_T_ID)) + ->identifier("product") + ->inList("items") + ->name(__("Product")) + ->group($groupName) + ->microData("http://schema.org/Product", "productID") + ->association("name@items", "quantity@items", "subtotal@items") + ->isReadOnly() + ->isNotTested() + ; + //====================================================================// + // Credit Note Line Quantity + // + // Negative, like every other figure on a credit note: WooCommerce stores + // refunded quantities as negative on the refund's line items. + $this->fieldsFactory()->create(SPL_T_INT) + ->identifier("quantity") + ->inList("items") + ->name(__("Quantity")) + ->group($groupName) + ->microData("http://schema.org/QuantitativeValue", "value") + ->association("name@items", "quantity@items", "subtotal@items") + ->isReadOnly() + ; + //====================================================================// + // Credit Note Line Subtotal + $this->fieldsFactory()->create(SPL_T_PRICE) + ->identifier("subtotal") + ->inList("items") + ->name(__("Subtotal")) + ->group($groupName) + ->microData("http://schema.org/PriceSpecification", "price") + ->association("name@items", "quantity@items", "subtotal@items") + ->isReadOnly() + ; + //====================================================================// + // Line is Synthesised, not read from WooCommerce + $this->fieldsFactory()->create(SPL_T_BOOL) + ->identifier("is_virtual_item") + ->inList("items") + ->name(__("Amount-only refund")) + ->description(__("Line was rebuilt from the refund amount, not itemised in WooCommerce")) + ->group($groupName) + ->isReadOnly() + ; + } + + //====================================================================// + // Fields Reading Functions + //====================================================================// + + /** + * Read requested Field + * + * @param string $key Input List Key + * @param string $fieldName Field Identifier / Name + * + * @return void + */ + protected function getItemsFields(string $key, string $fieldName): void + { + //====================================================================// + // Check if List field & Init List Array + $fieldId = self::lists()->initOutput($this->out, "items", $fieldName); + if (!$fieldId) { + return; + } + //====================================================================// + // Walk on Credit Note Lines + foreach ($this->getCreditNoteLines() as $index => $line) { + self::lists()->insert($this->out, "items", $fieldId, $index, $line[$fieldId] ?? null); + } + + unset($this->in[$key]); + } + + //====================================================================// + // Private Helpers + //====================================================================// + + /** + * Build the list of Credit Note Lines + * + * @return array> + */ + private function getCreditNoteLines(): array + { + $lines = array(); + //====================================================================// + // Read the refund's own line items, when it has any. + /** @var WC_Order_Item $item */ + foreach ($this->object->get_items() as $item) { + $lines[] = $this->toCreditNoteLine($item); + } + if (!empty($lines)) { + return $lines; + } + //====================================================================// + // Amount-only refund: rebuild a single line so the remote server has + // something to write the credit note against. + return array($this->toVirtualLine()); + } + + /** + * Convert a WooCommerce Refund Item to a Credit Note Line + * + * @param WC_Order_Item $item + * + * @return array + */ + private function toCreditNoteLine(WC_Order_Item $item): array + { + $productId = null; + $quantity = 0; + if ($item instanceof WC_Order_Item_Product) { + $wcProductId = $item->get_variation_id() ?: $item->get_product_id(); + $productId = $wcProductId + ? self::objects()->encode("Product", (string) $wcProductId) + : null; + $quantity = (int) $item->get_quantity(); + } + + $subtotal = (float) $item->get_total(); + $subtotalTax = (float) $item->get_total_tax(); + + return array( + "name" => (string) $item->get_name(), + "product" => $productId, + "quantity" => $quantity, + "subtotal" => self::toCreditPrice($subtotal, $subtotalTax), + "is_virtual_item" => false, + ); + } + + /** + * Build the single line that stands for an amount-only refund + * + * @return array + */ + private function toVirtualLine(): array + { + //====================================================================// + // The reason is what a human typed to explain the refund, so it makes the + // best line description. Fall back to a neutral label when it is empty. + $reason = trim((string) $this->object->get_reason()); + $name = $reason ?: __("Refund"); + + $totalTax = (float) $this->object->get_total_tax(); + $totalHt = (float) $this->object->get_total() - $totalTax; + + return array( + "name" => $name, + "product" => null, + "quantity" => -1, + "subtotal" => self::toCreditPrice($totalHt, $totalTax), + "is_virtual_item" => true, + ); + } +} diff --git a/src/Objects/CreditNote/ObjectListTrait.php b/src/Objects/CreditNote/ObjectListTrait.php new file mode 100644 index 0000000..b196a26 --- /dev/null +++ b/src/Objects/CreditNote/ObjectListTrait.php @@ -0,0 +1,141 @@ + + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Splash\Local\Objects\CreditNote; + +use Splash\Core\SplashCore as Splash; +use WC_Order_Refund; + +/** + * WooCommerce Credit Note Objects Lists + */ +trait ObjectListTrait +{ + /** + * Build Objects List + * + * @param null|string $filter Filters for Object List. + * @param array $params Search parameters for result List. + * + * @return array + */ + public function objectsList(string $filter = null, array $params = array()): array + { + //====================================================================// + // Stack Trace + Splash::log()->trace(); + $data = array(); + //====================================================================// + // Load Data From DataBase + // + // Refunds are fetched by type rather than by status: a refund inherits the + // status of its parent order and filtering on it would hide legitimate + // credit notes. + $rawData = wc_get_orders(array( + 'type' => 'shop_order_refund', + 'status' => 'any', + 'limit' => (!empty($params["max"]) ? $params["max"] : 10), + 'offset' => (!empty($params["offset"]) ? $params["offset"] : 0), + 'orderby' => (!empty($params["sortfield"]) ? $params["sortfield"] : 'id'), + 'order' => (!empty($params["sortorder"]) ? $params["sortorder"] : 'ASC'), + 's' => (!empty($filter) ? $filter : ''), + )); + if (!is_array($rawData)) { + $rawData = array(); + } + //====================================================================// + // Store Meta Total & Current values + $data["meta"]["total"] = $this->countCreditNotes(); + $data["meta"]["current"] = count($rawData); + //====================================================================// + // For each result, read information and add to $data + foreach ($rawData as $wcRefund) { + if (!$wcRefund instanceof WC_Order_Refund) { + continue; + } + $data[] = $this->toListCreditNote($wcRefund); + } + Splash::log()->deb( + "MsgLocalTpl", + __CLASS__, + __FUNCTION__, + " ".count($rawData)." Credit Notes Found." + ); + + return $data; + } + + /** + * Count Total Number of Credit Notes + * + * wc_orders_count() only knows about order statuses, so it cannot count + * refunds. Ask WordPress for the post type count instead. + * + * @return int + */ + private function countCreditNotes(): int + { + global $wpdb; + + return (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s", + 'shop_order_refund' + )); + } + + /** + * Prepare a Credit Note for the Objects List + * + * @param WC_Order_Refund $wcRefund + * + * @return array + */ + private function toListCreditNote(WC_Order_Refund $wcRefund): array + { + $date = $wcRefund->get_date_created(); + + $parent = wc_get_order($wcRefund->get_parent_id()); + $parentTotal = $parent ? (float) $parent->get_total() : 0.0; + + return array( + "id" => $wcRefund->get_id(), + "reference" => $this->toListReference($wcRefund), + "_datetime_created" => $date ? $date->format(SPL_T_DATETIMECAST) : null, + "reason" => (string) $wcRefund->get_reason(), + "is_order_fully_refunded" => ($parent && $parentTotal > 0.0) + && (((float) $parent->get_total_refunded() + 0.01) >= $parentTotal), + "total" => $wcRefund->get_total(), + ); + } + + /** + * Build a Credit Note Reference for the List + * + * Kept in step with CoreTrait::getCreditNoteReference(). + * + * @param WC_Order_Refund $wcRefund + * + * @return string + */ + private function toListReference(WC_Order_Refund $wcRefund): string + { + $ywpiNumber = (string) $wcRefund->get_meta('_ywpi_credit_note_formatted_number'); + if (!empty($ywpiNumber)) { + return $ywpiNumber; + } + + return sprintf("#%d-R%d", $wcRefund->get_parent_id(), $wcRefund->get_id()); + } +} diff --git a/src/Objects/CreditNote/TotalsTrait.php b/src/Objects/CreditNote/TotalsTrait.php new file mode 100644 index 0000000..635f274 --- /dev/null +++ b/src/Objects/CreditNote/TotalsTrait.php @@ -0,0 +1,164 @@ + + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Splash\Local\Objects\CreditNote; + +/** + * WooCommerce Credit Note Totals + * + * SIGNS + * WooCommerce exposes the same money twice, with opposite signs: + * - get_total() returns the signed value, always negative (-118.00) + * - get_amount() returns the absolute value, always positive ( 118.00) + * + * Everything here is built on get_total(), so amounts arrive at the remote server + * already negated, the way a credit note is expected to read. `amount` is exposed + * separately for servers that prefer a positive figure — Dolibarr among them, since + * it stores credit notes positive and lets its own CreditModeTrait flip the sign. + */ +trait TotalsTrait +{ + //====================================================================// + // Fields Generation Functions + //====================================================================// + + /** + * Build Totals Fields using FieldFactory + * + * @return void + */ + protected function buildTotalsFields(): void + { + $groupName = __("Totals"); + + //====================================================================// + // Credit Note Total Tax Excluded (negative) + $this->fieldsFactory()->create(SPL_T_DOUBLE) + ->identifier("total_ht") + ->name(__("Total")." (Tax Excl.)") + ->microData("http://schema.org/Invoice", "totalPaymentDue") + ->group($groupName) + ->isReadOnly() + ; + //====================================================================// + // Credit Note Total Tax Included (negative) + $this->fieldsFactory()->create(SPL_T_DOUBLE) + ->identifier("total") + ->name(__("Total")." (Tax Incl.)") + ->microData("http://schema.org/Invoice", "totalPaymentDueTaxIncluded") + ->group($groupName) + ->isReadOnly() + ->isListed() + ; + //====================================================================// + // Refunded Amount (positive) + $this->fieldsFactory()->create(SPL_T_DOUBLE) + ->identifier("amount") + ->name(__("Refund amount")) + ->description(__("Refunded amount, as a positive value")) + ->group($groupName) + ->isReadOnly() + ; + //====================================================================// + // Credit Note Total Price + $this->fieldsFactory()->create(SPL_T_PRICE) + ->identifier("price_total") + ->name(__("Total")) + ->microData("http://schema.org/Invoice", "total") + ->group($groupName) + ->isReadOnly() + ; + } + + //====================================================================// + // Fields Reading Functions + //====================================================================// + + /** + * Read requested Field + * + * @param string $key Input List Key + * @param string $fieldName Field Identifier / Name + * + * @return void + */ + protected function getTotalsFields(string $key, string $fieldName): void + { + //====================================================================// + // READ Fields + switch ($fieldName) { + case 'total_ht': + $this->out[$fieldName] = (float) $this->object->get_total() + - (float) $this->object->get_total_tax(); + + break; + case 'total': + $this->out[$fieldName] = (float) $this->object->get_total(); + + break; + case 'amount': + $this->out[$fieldName] = (float) $this->object->get_amount(); + + break; + case 'price_total': + $this->out[$fieldName] = self::toCreditPrice( + (float) $this->object->get_total() - (float) $this->object->get_total_tax(), + (float) $this->object->get_total_tax() + ); + + break; + default: + return; + } + + unset($this->in[$key]); + } + + //====================================================================// + // Private Helpers + //====================================================================// + + /** + * Encode a Credit Note Price + * + * This is Order\TotalsTrait::toTotalPrice() adapted to negative amounts. The + * Order version guards its VAT rate computation with `$totalTaxExcl > 0`, + * which is never true on a credit note and would silently report every credit + * note as 0% VAT. The rate is computed on absolute values instead. + * + * @param float $totalTaxExcl + * @param float $totalTax + * + * @return null|array + */ + private static function toCreditPrice(float $totalTaxExcl, float $totalTax): ?array + { + $totalTaxIncl = $totalTaxExcl + $totalTax; + //====================================================================// + // Compute VAT Rate on absolute values: both figures are negative here. + $vatRate = (abs($totalTaxExcl) > 0.0) + ? 100 * abs($totalTax) / abs($totalTaxExcl) + : 0.0 + ; + + return self::prices()->encode( + null, + $vatRate, + $totalTaxIncl, + get_woocommerce_currency(), + get_woocommerce_currency_symbol() + ); + } +}