| @@ -19,8 +19,9 @@ | ||
| 19 | 19 | use FluentCart\Framework\Database\Orm\Relations\HasManyThrough; |
| 20 | 20 | use FluentCart\Framework\Database\Orm\Relations\HasOne; |
| 21 | 21 | use FluentCart\Framework\Database\Orm\Relations\MorphMany; |
| 22 | 22 | use FluentCart\Framework\Support\Arr; |
| 23 | +use FluentCart\App\Services\Renderer\Receipt\TaxSummaryHelper; | |
| 23 | 24 | use FluentCartPro\App\Modules\Licensing\Models\License; |
| 24 | 25 | |
| 25 | 26 | /** |
| 26 | 27 | * Order Model - DB Model for Orders |
| @@ -41,9 +42,9 @@ | ||
| 41 | 42 | { |
| 42 | 43 | parent::boot(); |
| 43 | 44 | static::creating(function ($model) { |
| 44 | 45 | if (empty($model->uuid)) { |
| 45 | - $model->uuid = md5(time() . wp_generate_uuid4()); | |
| 46 | + $model->uuid = static::generateOrderUuid(); | |
| 46 | 47 | } |
| 47 | 48 | |
| 48 | 49 | if (!isset($model->config)) { |
| 49 | 50 | $model->config = []; |
| @@ -55,8 +56,18 @@ | ||
| 55 | 56 | } |
| 56 | 57 | }); |
| 57 | 58 | |
| 58 | 59 | static::created(function ($model) { |
| 60 | + // Every order carries a companion operations row. ReceiptHandler reads | |
| 61 | + // sales_recorded off it to decide whether a receipt is being seen for the | |
| 62 | + // first time, and that gates fluent_cart/after_receipt_first_time — an order | |
| 63 | + // without the row silently never fires its purchase event. | |
| 64 | + // | |
| 65 | + // Created here rather than at each call site because orders also come from | |
| 66 | + // renewals, subscription child orders, the admin and WP-CLI, none of which | |
| 67 | + // pass through the checkout or dispatch fluent_cart/order_created. | |
| 68 | + OrderOperation::query()->firstOrCreate(['order_id' => $model->id]); | |
| 69 | + | |
| 59 | 70 | if ($model->invoice_no) { |
| 60 | 71 | do_action('fluent_cart/order/invoice_number_added', [ |
| 61 | 72 | 'order' => $model |
| 62 | 73 | ]); |
| @@ -63,8 +74,59 @@ | ||
| 63 | 74 | } |
| 64 | 75 | }); |
| 65 | 76 | } |
| 66 | 77 | |
| 78 | + /** | |
| 79 | + * Generate a short, human-usable order handle: 12 uppercase alphanumeric | |
| 80 | + * characters (e.g. A7K2P9X4M1Q8), stored in the `uuid` column and shown as | |
| 81 | + * "#A7K2P9X4M1Q8" on the UI. Existing orders keep their legacy md5 uuids. | |
| 82 | + * | |
| 83 | + * Uniqueness is best-effort at the application level: a chunk of | |
| 84 | + * candidates is generated and filtered against the table with a single | |
| 85 | + * whereIn query (no per-candidate round-trips). There is intentionally no | |
| 86 | + * DB unique constraint on `fct_orders.uuid`, so this check is NOT atomic — | |
| 87 | + * two concurrent inserts could theoretically race on the same candidate. | |
| 88 | + * Given the 36^12 (~4.7x10^18) space, a real collision is astronomically | |
| 89 | + * unlikely, but not cryptographically guaranteed. If a hard guarantee is | |
| 90 | + * ever required, add a unique index on the column and retry creation on a | |
| 91 | + * duplicate-key error. | |
| 92 | + * | |
| 93 | + * @return string | |
| 94 | + */ | |
| 95 | + public static function generateOrderUuid() | |
| 96 | + { | |
| 97 | + $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; | |
| 98 | + $maxIndex = strlen($chars) - 1; | |
| 99 | + $chunkSize = 20; | |
| 100 | + | |
| 101 | + do { | |
| 102 | + // Generate a chunk of candidates, then resolve collisions with a | |
| 103 | + // single query (whereIn) instead of one query per candidate. The | |
| 104 | + // code is used as the array key so the chunk is self-deduplicated. | |
| 105 | + $candidates = []; | |
| 106 | + for ($i = 0; $i < $chunkSize; $i++) { | |
| 107 | + $code = ''; | |
| 108 | + for ($j = 0; $j < 12; $j++) { | |
| 109 | + $code .= $chars[wp_rand(0, $maxIndex)]; | |
| 110 | + } | |
| 111 | + $candidates[$code] = true; | |
| 112 | + } | |
| 113 | + | |
| 114 | + $taken = static::whereIn('uuid', array_keys($candidates)) | |
| 115 | + ->get(['uuid']) | |
| 116 | + ->pluck('uuid') | |
| 117 | + ->toArray(); | |
| 118 | + | |
| 119 | + foreach ($taken as $existing) { | |
| 120 | + unset($candidates[$existing]); | |
| 121 | + } | |
| 122 | + // Loops again only if every candidate in the chunk collided, which | |
| 123 | + // is astronomically unlikely for a 36^12 (~4.7x10^18) space. | |
| 124 | + } while (empty($candidates)); | |
| 125 | + | |
| 126 | + return array_key_first($candidates); | |
| 127 | + } | |
| 128 | + | |
| 67 | 129 | protected $fillable = [ |
| 68 | 130 | 'status', |
| 69 | 131 | 'parent_id', |
| 70 | 132 | 'invoice_no', |
| @@ -119,8 +181,9 @@ | ||
| 119 | 181 | 'shipping_tax' => 'double', |
| 120 | 182 | 'shipping_total' => 'double', |
| 121 | 183 | 'fee_total' => 'double', |
| 122 | 184 | 'tax_total' => 'double', |
| 185 | + 'tax_behavior' => 'integer', | |
| 123 | 186 | 'total_amount' => 'double', |
| 124 | 187 | 'customer_id' => 'integer', |
| 125 | 188 | ]; |
| 126 | 189 | |
| @@ -464,13 +527,336 @@ | ||
| 464 | 527 | ->where('meta_key', $metaKey) |
| 465 | 528 | ->delete(); |
| 466 | 529 | } |
| 467 | 530 | |
| 531 | + public function getBusinessInfo(): array | |
| 532 | + { | |
| 533 | + $businessInfo = $this->getMeta('business_info', []); | |
| 534 | + | |
| 535 | + return is_array($businessInfo) ? $businessInfo : []; | |
| 536 | + } | |
| 537 | + | |
| 538 | + public function getPrimaryOrderTaxRate() | |
| 539 | + { | |
| 540 | + return $this->orderTaxRates ? $this->orderTaxRates->first() : null; | |
| 541 | + } | |
| 542 | + | |
| 543 | + public function getReversedTaxTotal() | |
| 544 | + { | |
| 545 | + $this->loadMissing(['orderTaxRates']); | |
| 546 | + $primaryRate = $this->getPrimaryOrderTaxRate(); | |
| 547 | + if (!$primaryRate) { | |
| 548 | + return 0; | |
| 549 | + } | |
| 550 | + return (int) Arr::get( | |
| 551 | + is_array($primaryRate->meta) ? $primaryRate->meta : [], | |
| 552 | + 'reverse_charge_original_tax_total', | |
| 553 | + 0 | |
| 554 | + ); | |
| 555 | + } | |
| 556 | + | |
| 557 | + public function isB2BOrder(): bool | |
| 558 | + { | |
| 559 | + return !empty($this->getBusinessInfo()); | |
| 560 | + } | |
| 561 | + | |
| 562 | + public function getIsB2BOrderAttribute(): bool | |
| 563 | + { | |
| 564 | + return $this->isB2BOrder(); | |
| 565 | + } | |
| 566 | + | |
| 567 | + public function isReverseChargeTaxOrder(): bool | |
| 568 | + { | |
| 569 | + $orderTaxRate = $this->getPrimaryOrderTaxRate(); | |
| 570 | + $reverseChargeApplied = Arr::get($orderTaxRate->meta ?? [], 'reverse_charge_applied', null); | |
| 571 | + | |
| 572 | + if ($reverseChargeApplied !== null) { | |
| 573 | + return (bool) $reverseChargeApplied; | |
| 574 | + } | |
| 575 | + | |
| 576 | + return $this->hasValidatedCustomerTaxNumber() && ((int) $this->tax_total + (int) $this->shipping_tax) === 0; | |
| 577 | + } | |
| 578 | + | |
| 579 | + public function getOrderRcMode(): string | |
| 580 | + { | |
| 581 | + $this->loadMissing(['orderTaxRates']); | |
| 582 | + $primaryRate = $this->getPrimaryOrderTaxRate(); | |
| 583 | + $stored = Arr::get((array) ($primaryRate ? $primaryRate->meta : []), 'reverse_charge_price_mode', null); | |
| 584 | + if ($stored !== null) { | |
| 585 | + return (string) $stored; | |
| 586 | + } | |
| 587 | + return (string) Arr::get( | |
| 588 | + get_option('fluent_cart_tax_configuration_settings', []), | |
| 589 | + 'eu_vat_settings.reverse_charge_price_mode', | |
| 590 | + 'fixed' | |
| 591 | + ); | |
| 592 | + } | |
| 593 | + | |
| 594 | + public function getDisplayTaxLines(): array | |
| 595 | + { | |
| 596 | + $displayTaxLines = []; | |
| 597 | + $isReverseCharge = $this->isReverseChargeTaxOrder(); | |
| 598 | + | |
| 599 | + foreach ($this->orderTaxRates ?: [] as $orderTaxRate) { | |
| 600 | + $meta = $this->normalizeOrderTaxRateMeta((array) $orderTaxRate->meta, (int) $orderTaxRate->tax_rate_id); | |
| 601 | + | |
| 602 | + $ratePercent = (float) Arr::get($meta, 'rate_percent', 0); | |
| 603 | + $rateTaxAmount = (int) $orderTaxRate->order_tax; | |
| 604 | + $taxableAmount = (int) Arr::get($meta, 'taxable_amount', 0); | |
| 605 | + $isCompound = (bool) Arr::get($meta, 'is_compound', false); | |
| 606 | + $isMixedInclusive = (bool) Arr::get($meta, 'is_mixed_inclusive', false); | |
| 607 | + $lineInclusive = Arr::get($meta, 'inclusive', null); | |
| 608 | + $label = trim((string) Arr::get($meta, 'label', '')); | |
| 609 | + | |
| 610 | + if ($isReverseCharge) { | |
| 611 | + if ($ratePercent <= 0) { | |
| 612 | + continue; | |
| 613 | + } | |
| 614 | + } elseif ($rateTaxAmount <= 0) { | |
| 615 | + continue; | |
| 616 | + } | |
| 617 | + | |
| 618 | + $displayLabel = $label ?: __('Tax', 'fluent-cart'); | |
| 619 | + | |
| 620 | + if ($ratePercent > 0) { | |
| 621 | + $displayLabel .= ' (' . Helper::formatTaxRatePercent($ratePercent) . '%)'; | |
| 622 | + } | |
| 623 | + | |
| 624 | + if ($isCompound) { | |
| 625 | + $displayLabel .= ' (' . __('Compound', 'fluent-cart') . ')'; | |
| 626 | + } | |
| 627 | + | |
| 628 | + // Capture clean label (name + percent, no base suffix) for the shared folded-row builder. | |
| 629 | + $rateLabelClean = $displayLabel; | |
| 630 | + | |
| 631 | + if ($taxableAmount > 0 && !$isMixedInclusive) { | |
| 632 | + if ($lineInclusive === null) { | |
| 633 | + $displayBase = $this->tax_behavior == 2 | |
| 634 | + ? max(0, $taxableAmount - $rateTaxAmount) | |
| 635 | + : $taxableAmount; | |
| 636 | + } else { | |
| 637 | + $displayBase = $lineInclusive | |
| 638 | + ? max(0, $taxableAmount - $rateTaxAmount) | |
| 639 | + : $taxableAmount; | |
| 640 | + } | |
| 641 | + | |
| 642 | + $displayLabel .= ' ' . sprintf( | |
| 643 | + __('on %s', 'fluent-cart'), | |
| 644 | + html_entity_decode(Helper::toDecimal($displayBase), ENT_QUOTES, 'UTF-8') | |
| 645 | + ); | |
| 646 | + } | |
| 647 | + | |
| 648 | + $displayTaxLines[] = [ | |
| 649 | + 'label' => $displayLabel, | |
| 650 | + 'rate_label' => $rateLabelClean, | |
| 651 | + 'order_tax' => $rateTaxAmount, | |
| 652 | + 'total_tax' => (int) $orderTaxRate->total_tax, | |
| 653 | + 'rate_id' => (int) $orderTaxRate->tax_rate_id, | |
| 654 | + 'rate_percent' => $ratePercent, | |
| 655 | + 'taxable_amount' => isset($displayBase) ? (int) $displayBase : $taxableAmount, | |
| 656 | + 'inclusive' => $lineInclusive === null ? ((int) $this->tax_behavior === 2) : (bool) $lineInclusive, | |
| 657 | + ]; | |
| 658 | + unset($displayBase); | |
| 659 | + } | |
| 660 | + | |
| 661 | + return $displayTaxLines; | |
| 662 | + } | |
| 663 | + | |
| 664 | + public function getDisplayTaxLinesAttribute(): array | |
| 665 | + { | |
| 666 | + return $this->getDisplayTaxLines(); | |
| 667 | + } | |
| 668 | + | |
| 669 | + public function getDisplayShippingTaxLines(): array | |
| 670 | + { | |
| 671 | + $displayLines = []; | |
| 672 | + foreach ($this->orderTaxRates ?: [] as $orderTaxRate) { | |
| 673 | + $shippingTax = (int) $orderTaxRate->shipping_tax; | |
| 674 | + if ($shippingTax <= 0) { | |
| 675 | + continue; | |
| 676 | + } | |
| 677 | + $meta = $this->normalizeOrderTaxRateMeta((array) $orderTaxRate->meta, (int) $orderTaxRate->tax_rate_id); | |
| 678 | + $ratePercent = (float) Arr::get($meta, 'rate_percent', 0); | |
| 679 | + $label = trim((string) Arr::get($meta, 'label', '')); | |
| 680 | + $rateName = $label ?: __('Tax', 'fluent-cart'); | |
| 681 | + if ($ratePercent > 0) { | |
| 682 | + $formattedRatePercent = Helper::formatTaxRatePercent($ratePercent); | |
| 683 | + /* translators: %1$s: tax rate name e.g. "VAT", %2$s: rate percentage e.g. "19" */ | |
| 684 | + $displayLabel = sprintf(__('%1$s (%2$s%%) on shipping', 'fluent-cart'), $rateName, $formattedRatePercent); | |
| 685 | + } else { | |
| 686 | + /* translators: %1$s: tax rate name e.g. "VAT" */ | |
| 687 | + $displayLabel = sprintf(__('%1$s on shipping', 'fluent-cart'), $rateName); | |
| 688 | + } | |
| 689 | + $displayLines[] = [ | |
| 690 | + 'label' => $displayLabel, | |
| 691 | + 'shipping_tax' => $shippingTax, | |
| 692 | + 'rate_id' => (int) $orderTaxRate->tax_rate_id, | |
| 693 | + 'rate_percent' => $ratePercent, | |
| 694 | + ]; | |
| 695 | + } | |
| 696 | + return $displayLines; | |
| 697 | + } | |
| 698 | + | |
| 699 | + public function getDisplayShippingTaxLinesAttribute(): array | |
| 700 | + { | |
| 701 | + return $this->getDisplayShippingTaxLines(); | |
| 702 | + } | |
| 703 | + | |
| 704 | + protected function normalizeOrderTaxRateMeta(array $meta, int $taxRateId): array | |
| 705 | + { | |
| 706 | + if ( | |
| 707 | + array_key_exists('label', $meta) || | |
| 708 | + array_key_exists('rate_percent', $meta) || | |
| 709 | + array_key_exists('taxable_amount', $meta) || | |
| 710 | + array_key_exists('is_compound', $meta) | |
| 711 | + ) { | |
| 712 | + return $meta; | |
| 713 | + } | |
| 714 | + | |
| 715 | + $legacyRates = (array) Arr::get($meta, 'rates', []); | |
| 716 | + if (!$legacyRates) { | |
| 717 | + return $meta; | |
| 718 | + } | |
| 719 | + | |
| 720 | + $legacyRateMeta = []; | |
| 721 | + | |
| 722 | + foreach ($legacyRates as $legacyRate) { | |
| 723 | + if ((int) Arr::get($legacyRate, 'rate_id', 0) === $taxRateId) { | |
| 724 | + $legacyRateMeta = (array) $legacyRate; | |
| 725 | + break; | |
| 726 | + } | |
| 727 | + } | |
| 728 | + | |
| 729 | + if (!$legacyRateMeta) { | |
| 730 | + $legacyRateMeta = (array) reset($legacyRates); | |
| 731 | + } | |
| 732 | + | |
| 733 | + if (!$legacyRateMeta) { | |
| 734 | + return $meta; | |
| 735 | + } | |
| 736 | + | |
| 737 | + return array_merge($meta, [ | |
| 738 | + 'label' => Arr::get($legacyRateMeta, 'label', ''), | |
| 739 | + 'rate_percent' => (float) Arr::get($legacyRateMeta, 'rate_percent', Arr::get($legacyRateMeta, 'rate', 0)), | |
| 740 | + 'taxable_amount' => (int) Arr::get($legacyRateMeta, 'taxable_amount', 0), | |
| 741 | + 'is_compound' => (bool) Arr::get($legacyRateMeta, 'is_compound', false), | |
| 742 | + 'inclusive' => Arr::get($legacyRateMeta, 'inclusive', null), | |
| 743 | + 'is_mixed_inclusive' => (bool) Arr::get($legacyRateMeta, 'is_mixed_inclusive', false), | |
| 744 | + ]); | |
| 745 | + } | |
| 746 | + | |
| 747 | + public function getCustomerTaxNumber(): string | |
| 748 | + { | |
| 749 | + $businessInfoTaxNumber = (string) Arr::get($this->getBusinessInfo(), 'tax_number', ''); | |
| 750 | + if (!empty($businessInfoTaxNumber)) { | |
| 751 | + return $businessInfoTaxNumber; | |
| 752 | + } | |
| 753 | + | |
| 754 | + $legacyTaxNumber = (string) $this->getMeta('vat_tax_id', ''); | |
| 755 | + if (!empty($legacyTaxNumber)) { | |
| 756 | + return $legacyTaxNumber; | |
| 757 | + } | |
| 758 | + | |
| 759 | + $topLevelLegacyTaxId = (string) $this->getMeta('tax_id', ''); | |
| 760 | + if (!empty($topLevelLegacyTaxId)) { | |
| 761 | + return $topLevelLegacyTaxId; | |
| 762 | + } | |
| 763 | + | |
| 764 | + $orderTaxRate = $this->getPrimaryOrderTaxRate(); | |
| 765 | + | |
| 766 | + return (string) Arr::get($orderTaxRate->meta ?? [], 'vat_reverse.vat_number', ''); | |
| 767 | + } | |
| 768 | + | |
| 769 | + public function getTaxSummaryAttribute(): array | |
| 770 | + { | |
| 771 | + return TaxSummaryHelper::computeTaxSummary($this); | |
| 772 | + } | |
| 773 | + | |
| 774 | + public function getBusinessInfoAttribute(): array | |
| 775 | + { | |
| 776 | + $businessInfo = $this->getBusinessInfo(); | |
| 777 | + | |
| 778 | + // Only meaningful for a MoR reverse-charge order (no orderTaxRate row) — omit | |
| 779 | + // for every normal order instead of appending a redundant duplicate of total_paid. | |
| 780 | + $morVatRemoved = $this->getMoRVatRemovedAmount(); | |
| 781 | + if ($morVatRemoved > 0) { | |
| 782 | + $businessInfo['net_total_paid'] = $this->netAmount((int) $this->total_paid); | |
| 783 | + } | |
| 784 | + | |
| 785 | + return $businessInfo; | |
| 786 | + } | |
| 787 | + | |
| 788 | + public function getIsReverseChargeTaxOrderAttribute(): bool | |
| 789 | + { | |
| 790 | + return $this->isReverseChargeTaxOrder(); | |
| 791 | + } | |
| 792 | + | |
| 793 | + public function getCustomerTaxNumberAttribute(): string | |
| 794 | + { | |
| 795 | + return $this->getCustomerTaxNumber(); | |
| 796 | + } | |
| 797 | + | |
| 798 | + public function hasValidatedCustomerTaxNumber(): bool | |
| 799 | + { | |
| 800 | + $businessInfo = $this->getBusinessInfo(); | |
| 801 | + if (!empty($businessInfo['tax_number'])) { | |
| 802 | + return (bool) Arr::get($businessInfo, 'tax_number_validated', false); | |
| 803 | + } | |
| 804 | + | |
| 805 | + $orderTaxRate = $this->getPrimaryOrderTaxRate(); | |
| 806 | + | |
| 807 | + return (bool) Arr::get($orderTaxRate->meta ?? [], 'vat_reverse.valid', false); | |
| 808 | + } | |
| 809 | + | |
| 810 | + public function getCustomerTaxName(): string | |
| 811 | + { | |
| 812 | + $businessInfoTaxName = (string) Arr::get($this->getBusinessInfo(), 'tax_number_name', ''); | |
| 813 | + if (!empty($businessInfoTaxName)) { | |
| 814 | + return $businessInfoTaxName; | |
| 815 | + } | |
| 816 | + | |
| 817 | + $orderTaxRate = $this->getPrimaryOrderTaxRate(); | |
| 818 | + | |
| 819 | + return (string) Arr::get($orderTaxRate->meta ?? [], 'vat_reverse.name', ''); | |
| 820 | + } | |
| 821 | + | |
| 468 | 822 | public function getTotalPaidAmount() |
| 469 | 823 | { |
| 470 | 824 | return $this->transactions()->where('status', Status::TRANSACTION_SUCCEEDED)->sum('total'); |
| 471 | 825 | } |
| 472 | 826 | |
| 827 | + /** | |
| 828 | + * Amount of VAT removed by a merchant-of-record gateway (e.g. Paddle) for a | |
| 829 | + * reverse-charge order that never ran the tax module (no `fct_order_tax_rate` row). | |
| 830 | + * Display-only — `total_amount`/`total_paid`/`txn.total` stay gross for such orders | |
| 831 | + * (cover invariant: the "fully paid" equality that drives due-amount checks, digital | |
| 832 | + * auto-complete, and dunning reminders depends on it), so this is never subtracted | |
| 833 | + * into the ledger, only into `getDisplayTotalPaid()` for the admin UI. Zero for | |
| 834 | + * core-handled reverse charge (tax-rate row present) — those are already net at | |
| 835 | + * creation time. | |
| 836 | + */ | |
| 837 | + public function getMoRVatRemovedAmount(): int | |
| 838 | + { | |
| 839 | + if ($this->getPrimaryOrderTaxRate()) { | |
| 840 | + return 0; | |
| 841 | + } | |
| 842 | + | |
| 843 | + return (int) Arr::get($this->getBusinessInfo(), 'mor_vat_removed', 0); | |
| 844 | + } | |
| 845 | + | |
| 846 | + /** | |
| 847 | + * Nets a raw gross ledger figure (total_paid, a live paid-total sum, a refund amount) | |
| 848 | + * by the MoR VAT removal — see getMoRVatRemovedAmount(). Single formula for every | |
| 849 | + * "what did we actually collect/need to refund" comparison, so callers never | |
| 850 | + * reimplement the subtraction themselves. Never use for due-amount, digital | |
| 851 | + * auto-complete, or reminder logic; those must keep comparing the gross ledger | |
| 852 | + * columns (total_amount vs total_paid) so the "fully paid" equality still holds. | |
| 853 | + */ | |
| 854 | + public function netAmount(int $amount): int | |
| 855 | + { | |
| 856 | + return max(0, $amount - $this->getMoRVatRemovedAmount()); | |
| 857 | + } | |
| 858 | + | |
| 473 | 859 | public function getTotalRefundAmount() |
| 474 | 860 | { |
| 475 | 861 | return $this->transactions()->where('status', Status::TRANSACTION_REFUNDED)->sum('total'); |
| 476 | 862 | } |
| @@ -481,9 +867,13 @@ | ||
| 481 | 867 | $totalRefunded = $this->getTotalRefundAmount(); |
| 482 | 868 | |
| 483 | 869 | $this->total_refund = $totalRefunded; |
| 484 | 870 | |
| 485 | - if (floatval($totalRefunded) >= floatval($totalPaid)) { | |
| 871 | + // Net out MoR VAT removal so a full refund of the actually captured amount | |
| 872 | + // resolves to "fully refunded" — see netAmount(). | |
| 873 | + $netTotalPaid = $this->netAmount($totalPaid); | |
| 874 | + | |
| 875 | + if (floatval($totalRefunded) >= floatval($netTotalPaid)) { | |
| 486 | 876 | $this->payment_status = Status::PAYMENT_REFUNDED; |
| 487 | 877 | } elseif ($totalPaid > $totalRefunded) { |
| 488 | 878 | $this->payment_status = Status::PAYMENT_PARTIALLY_REFUNDED; |
| 489 | 879 | } |
| @@ -497,8 +887,12 @@ | ||
| 497 | 887 | { |
| 498 | 888 | $paymentStatus = $type == 'full' ? Status::PAYMENT_REFUNDED : Status::PAYMENT_PARTIALLY_REFUNDED; |
| 499 | 889 | $this->total_refund += $refundedAmount; |
| 500 | 890 | $this->payment_status = $paymentStatus; |
| 891 | + | |
| 892 | + if ($paymentStatus === Status::PAYMENT_REFUNDED && !$this->refunded_at) { | |
| 893 | + $this->refunded_at = DateTime::gmtNow(); | |
| 894 | + } | |
| 501 | 895 | |
| 502 | 896 | $this->save(); |
| 503 | 897 | |
| 504 | 898 | return $this; |