| @@ -56,8 +56,32 @@ | ||
| 56 | 56 | $this->transaction = $transaction; |
| 57 | 57 | return $this; |
| 58 | 58 | } |
| 59 | 59 | |
| 60 | + /** | |
| 61 | + * Gateway-agnostic idempotency seed for the current charge attempt. The ONLY | |
| 62 | + * seed source for gateway idempotency keys — full contract in | |
| 63 | + * .claude/skills/coding-rules/payment-idempotency.md. | |
| 64 | + * | |
| 65 | + * Stable across duplicate submissions (transaction uuid survives draft-order | |
| 66 | + * re-submission -> gateway dedupes), fresh after an observed failure | |
| 67 | + * (payment_attempt is bumped when a FAILED transaction is re-submitted -> | |
| 68 | + * retries are never blocked or answered with a cached gateway error). | |
| 69 | + * | |
| 70 | + * @return string Empty string when no charge transaction exists — callers | |
| 71 | + * must skip idempotency rather than share a static key. | |
| 72 | + */ | |
| 73 | + public function getIdempotencySeed() | |
| 74 | + { | |
| 75 | + if (!$this->transaction || !$this->transaction->uuid) { | |
| 76 | + return ''; | |
| 77 | + } | |
| 78 | + | |
| 79 | + $attempt = (int) Arr::get($this->transaction->meta ?: [], 'payment_attempt', 0); | |
| 80 | + | |
| 81 | + return $this->transaction->uuid . (int) $this->transaction->total . ($attempt ? '_r' . $attempt : ''); | |
| 82 | + } | |
| 83 | + | |
| 60 | 84 | public function getExtraAddonAmount() |
| 61 | 85 | { |
| 62 | 86 | if (empty($this->subscription)) { |
| 63 | 87 | return 0; |
| @@ -82,19 +106,28 @@ | ||
| 82 | 106 | } |
| 83 | 107 | } |
| 84 | 108 | } |
| 85 | 109 | |
| 86 | - // shipping charge, in case addons are physical products | |
| 87 | - $shippingCharge = (int) $this->order->shipping_total; | |
| 88 | - if ($shippingCharge) { | |
| 89 | - $extraAmount += $shippingCharge; | |
| 110 | + // shipping for physical addons (digital sub + one-time physical items) | |
| 111 | + // skip when subscription itself is physical — shipping already in recurring_total | |
| 112 | + // if subscription is digital, but has physical addons, we need to add shipping for those addons, because shipping is not included in recurring_total for digital subscriptions | |
| 113 | + $subscriptionItem = $this->order->order_items->filter(function ($item) { | |
| 114 | + return $item->payment_type === 'subscription'; | |
| 115 | + })->first(); | |
| 116 | + $subscriptionIsPhysical = $subscriptionItem && $subscriptionItem->fulfillment_type === 'physical'; | |
| 90 | 117 | |
| 91 | - if ($taxBehavior === 1) { | |
| 92 | - $extraAmount += (int) $this->order->shipping_tax; | |
| 93 | - } elseif ($taxBehavior === 3) { | |
| 94 | - $storeTaxBehavior = (int) $this->order->getMeta('store_tax_behavior', 1); | |
| 95 | - if ($storeTaxBehavior === 1) { | |
| 118 | + if (!$subscriptionIsPhysical) { | |
| 119 | + $shippingCharge = (int) $this->order->shipping_total; | |
| 120 | + if ($shippingCharge) { | |
| 121 | + $extraAmount += $shippingCharge; | |
| 122 | + | |
| 123 | + if ($taxBehavior === 1) { | |
| 96 | 124 | $extraAmount += (int) $this->order->shipping_tax; |
| 125 | + } elseif ($taxBehavior === 3) { | |
| 126 | + $storeTaxBehavior = (int) $this->order->getMeta('store_tax_behavior', 1); | |
| 127 | + if ($storeTaxBehavior === 1) { | |
| 128 | + $extraAmount += (int) $this->order->shipping_tax; | |
| 129 | + } | |
| 97 | 130 | } |
| 98 | 131 | } |
| 99 | 132 | } |
| 100 | 133 | |