| @@ -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; |
| @@ -62,8 +86,10 @@ | ||
| 62 | 86 | if (empty($this->subscription)) { |
| 63 | 87 | return 0; |
| 64 | 88 | } |
| 65 | 89 | |
| 90 | + $taxBehavior = (int) $this->order->tax_behavior; | |
| 91 | + | |
| 66 | 92 | $extraItems = $this->order->order_items->filter(function ($item) { |
| 67 | 93 | return $item->payment_type !== 'subscription' && $item->payment_type !== 'signup_fee' && $item->payment_type !== 'fee'; |
| 68 | 94 | }); |
| 69 | 95 | |
| @@ -68,15 +94,42 @@ | ||
| 68 | 94 | }); |
| 69 | 95 | |
| 70 | 96 | $extraAmount = 0; |
| 71 | 97 | foreach ($extraItems as $item) { |
| 72 | - $extraAmount += $item->line_total; | |
| 98 | + $extraAmount += (int) $item->line_total; | |
| 99 | + | |
| 100 | + if ($taxBehavior === 1) { | |
| 101 | + $extraAmount += (int) $item->tax_amount; | |
| 102 | + } elseif ($taxBehavior === 3) { | |
| 103 | + $inclusive = (bool) Arr::get($item->line_meta, 'tax_config.inclusive', false); | |
| 104 | + if (!$inclusive) { | |
| 105 | + $extraAmount += (int) $item->tax_amount; | |
| 106 | + } | |
| 107 | + } | |
| 73 | 108 | } |
| 74 | 109 | |
| 75 | - // shipping charge, in case addons are physical products | |
| 76 | - $shippingCharge = $this->order->shipping_total; | |
| 77 | - if ($shippingCharge) { | |
| 78 | - $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'; | |
| 117 | + | |
| 118 | + if (!$subscriptionIsPhysical) { | |
| 119 | + $shippingCharge = (int) $this->order->shipping_total; | |
| 120 | + if ($shippingCharge) { | |
| 121 | + $extraAmount += $shippingCharge; | |
| 122 | + | |
| 123 | + if ($taxBehavior === 1) { | |
| 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 | + } | |
| 130 | + } | |
| 131 | + } | |
| 79 | 132 | } |
| 80 | 133 | |
| 81 | 134 | return $extraAmount; |
| 82 | 135 | } |