diff --git a/app/Services/Payments/PaddleClient.php b/app/Services/Payments/PaddleClient.php index 4dc7eb8..f2f23aa 100755 --- a/app/Services/Payments/PaddleClient.php +++ b/app/Services/Payments/PaddleClient.php @@ -2,6 +2,7 @@ namespace App\Services\Payments; +use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use RuntimeException; @@ -19,60 +20,147 @@ final class PaddleClient if (! $response->successful()) { return null; } + $data = $response->json('data'); return is_array($data) ? $data : null; } - public function updateSubscriptionPrice(string $subscriptionID, string $priceID): array + public function subscription(string $subscriptionID): array { $this->requireWritableConfiguration(); - $response = $this->request()->patch('/subscriptions/'.$subscriptionID, [ - 'proration_billing_mode' => 'prorated_immediately', - 'items' => [[ - 'price_id' => $priceID, - 'quantity' => 1, - ]], - ]); + $response = $this->request() + ->get('/subscriptions/'.$subscriptionID); - return $this->subscriptionData($response, 'Paddle could not update the subscription.'); + return $this->subscriptionData( + $response, + 'Paddle could not retrieve the subscription.' + ); } - public function cancelSubscriptionImmediately(string $subscriptionID): array - { + public function updateSubscriptionPrice( + string $subscriptionID, + string $priceID + ): array { $this->requireWritableConfiguration(); - $response = $this->request()->post('/subscriptions/'.$subscriptionID.'/cancel', [ - 'effective_from' => 'immediately', - ]); + /* + * Paddle restricts subscription item changes while a subscription + * is trialing. Existing item quantities may only be updated using + * do_not_bill during the trial. + * + * Once the subscription is outside its trial we can use normal + * immediate proration. + */ + $subscription = $this->subscription($subscriptionID); - return $this->subscriptionData($response, 'Paddle could not cancel the subscription.'); + $status = (string) ($subscription['status'] ?? ''); + + $prorationBillingMode = $status === 'trialing' + ? 'do_not_bill' + : 'prorated_immediately'; + + /* + * Preserve the existing subscription item and replace its price. + * + * Paddle treats the submitted items array as the desired + * subscription items, so quantity is retained where possible. + */ + $existingItems = is_array($subscription['items'] ?? null) + ? $subscription['items'] + : []; + + $existingItem = $existingItems[0] ?? []; + + $quantity = max( + 1, + (int) ($existingItem['quantity'] ?? 1) + ); + + $response = $this->request()->patch( + '/subscriptions/'.$subscriptionID, + [ + 'proration_billing_mode' => $prorationBillingMode, + 'items' => [ + [ + 'price_id' => $priceID, + 'quantity' => $quantity, + ], + ], + ] + ); + + return $this->subscriptionData( + $response, + 'Paddle could not update the subscription.' + ); } - private function request() + public function cancelSubscriptionImmediately( + string $subscriptionID + ): array { + $this->requireWritableConfiguration(); + + $response = $this->request()->post( + '/subscriptions/'.$subscriptionID.'/cancel', + [ + 'effective_from' => 'immediately', + ] + ); + + return $this->subscriptionData( + $response, + 'Paddle could not cancel the subscription.' + ); + } + + private function request(): PendingRequest { - return Http::baseUrl((string) config('sonardesk.paddle.api_url')) - ->withToken((string) config('sonardesk.paddle.api_key')) + return Http::baseUrl( + (string) config('sonardesk.paddle.api_url') + ) + ->withToken( + (string) config('sonardesk.paddle.api_key') + ) ->acceptJson() ->timeout(15); } private function requireWritableConfiguration(): void { - if (trim((string) config('sonardesk.paddle.api_key')) === '') { - throw new RuntimeException('Paddle subscription management is not configured.'); + if ( + trim( + (string) config('sonardesk.paddle.api_key') + ) === '' + ) { + throw new RuntimeException( + 'Paddle subscription management is not configured.' + ); } } - private function subscriptionData(Response $response, string $fallback): array - { + private function subscriptionData( + Response $response, + string $fallback + ): array { if (! $response->successful()) { - $detail = trim((string) ($response->json('error.detail') ?? '')); - throw new RuntimeException($detail !== '' ? $detail : $fallback); + $detail = trim( + (string) ( + $response->json('error.detail') + ?? '' + ) + ); + + throw new RuntimeException( + $detail !== '' + ? $detail + : $fallback + ); } $data = $response->json('data'); + if (! is_array($data)) { throw new RuntimeException($fallback); }