diff --git a/src/Pay/Adapter.php b/src/Pay/Adapter.php index 8912cdf..804c8fd 100644 --- a/src/Pay/Adapter.php +++ b/src/Pay/Adapter.php @@ -2,6 +2,15 @@ namespace Utopia\Pay; +use Utopia\Pay\Customer\Customer; +use Utopia\Pay\Dispute\Dispute; +use Utopia\Pay\Mandate\Mandate; +use Utopia\Pay\Payment\Payment; +use Utopia\Pay\PaymentMethod\PaymentMethod; +use Utopia\Pay\Refund\Refund; +use Utopia\Pay\SetupIntent\SetupIntent; +use Utopia\Pay\Webhook\WebhookEvent; + abstract class Adapter { /** @@ -58,9 +67,9 @@ public function getCurrency(): string * @param string $customerId Customer ID * @param string|null $paymentMethodId Payment method ID (optional) * @param array $additionalParams Additional parameters (optional) - * @return array Result of the purchase + * @return Payment Result of the purchase */ - abstract public function purchase(int $amount, string $customerId, ?string $paymentMethodId = null, array $additionalParams = []): array; + abstract public function purchase(int $amount, string $customerId, ?string $paymentMethodId = null, array $additionalParams = []): Payment; /** * Authorize a payment (hold funds without capturing) @@ -70,9 +79,9 @@ abstract public function purchase(int $amount, string $customerId, ?string $paym * @param string $customerId Customer ID * @param string|null $paymentMethodId Payment method ID (optional) * @param array $additionalParams Additional parameters (optional) - * @return array Result of the authorization including authorization ID + * @return Payment Result of the authorization including authorization ID */ - abstract public function authorize(int $amount, string $customerId, ?string $paymentMethodId = null, array $additionalParams = []): array; + abstract public function authorize(int $amount, string $customerId, ?string $paymentMethodId = null, array $additionalParams = []): Payment; /** * Capture a previously authorized payment @@ -81,9 +90,9 @@ abstract public function authorize(int $amount, string $customerId, ?string $pay * @param string $paymentId The payment/authorization ID to capture * @param int|null $amount Amount to capture (optional, defaults to full authorized amount) * @param array $additionalParams Additional parameters (optional) - * @return array Result of the capture + * @return Payment Result of the capture */ - abstract public function capture(string $paymentId, ?int $amount = null, array $additionalParams = []): array; + abstract public function capture(string $paymentId, ?int $amount = null, array $additionalParams = []): Payment; /** * Cancel/void a payment authorization @@ -91,9 +100,9 @@ abstract public function capture(string $paymentId, ?int $amount = null, array $ * * @param string $paymentId The payment/authorization ID to cancel * @param array $additionalParams Additional parameters (optional) - * @return array Result of the cancellation + * @return Payment Result of the cancellation */ - abstract public function cancelAuthorization(string $paymentId, array $additionalParams = []): array; + abstract public function cancelAuthorization(string $paymentId, array $additionalParams = []): Payment; /** * Update a payment intent @@ -103,9 +112,9 @@ abstract public function cancelAuthorization(string $paymentId, array $additiona * @param int|null $amount Amount to update (optional) * @param string|null $currency Currency to update (optional) * @param array $additionalParams Additional parameters (optional) - * @return array Result of the update + * @return Payment Result of the update */ - abstract public function updatePayment(string $paymentId, ?string $paymentMethodId = null, ?int $amount = null, ?string $currency = null, array $additionalParams = []): array; + abstract public function updatePayment(string $paymentId, ?string $paymentMethodId = null, ?int $amount = null, ?string $currency = null, array $additionalParams = []): Payment; /** * Retry a purchase for a payment intent @@ -113,9 +122,9 @@ abstract public function updatePayment(string $paymentId, ?string $paymentMethod * @param string $paymentId The payment intent ID to retry * @param string|null $paymentMethodId The payment method to use (optional) * @param array $additionalParams Additional parameters for the retry (optional) - * @return array The result of the retry attempt + * @return Payment The result of the retry attempt */ - abstract public function retryPurchase(string $paymentId, ?string $paymentMethodId = null, array $additionalParams = []): array; + abstract public function retryPurchase(string $paymentId, ?string $paymentMethodId = null, array $additionalParams = []): Payment; /** * Refund payment @@ -123,17 +132,17 @@ abstract public function retryPurchase(string $paymentId, ?string $paymentMethod * @param string $paymentId * @param int $amount * @param string $reason - * @return array + * @return Refund */ - abstract public function refund(string $paymentId, ?int $amount = null, ?string $reason = null): array; + abstract public function refund(string $paymentId, ?int $amount = null, ?string $reason = null): Refund; /** * Get a payment details * * @param string $paymentId - * @return array + * @return Payment */ - abstract public function getPayment(string $paymentId): array; + abstract public function getPayment(string $paymentId): Payment; /** * Add a payment method @@ -141,9 +150,9 @@ abstract public function getPayment(string $paymentId): array; * @param string $customerId * @param string $type * @param array $details - * @return array + * @return PaymentMethod */ - abstract public function createPaymentMethod(string $customerId, string $type, array $details): array; + abstract public function createPaymentMethod(string $customerId, string $type, array $details): PaymentMethod; /** * Update payment method billing details @@ -153,9 +162,9 @@ abstract public function createPaymentMethod(string $customerId, string $type, a * @param string|null $email * @param string|null $phone * @param array|null $address - * @return array + * @return PaymentMethod */ - abstract public function updatePaymentMethodBillingDetails(string $paymentMethodId, ?string $name = null, ?string $email = null, ?string $phone = null, ?array $address = null): array; + abstract public function updatePaymentMethodBillingDetails(string $paymentMethodId, ?string $name = null, ?string $email = null, ?string $phone = null, ?array $address = null): PaymentMethod; /** * Update payment method @@ -163,15 +172,15 @@ abstract public function updatePaymentMethodBillingDetails(string $paymentMethod * @param string $paymentMethodId * @param string $type * @param array $details - * @return array + * @return PaymentMethod */ - abstract public function updatePaymentMethod(string $paymentMethodId, string $type, array $details): array; + abstract public function updatePaymentMethod(string $paymentMethodId, string $type, array $details): PaymentMethod; /** * List payment methods * * @param string $customerId - * @return array + * @return array */ abstract public function listPaymentMethods(string $customerId): array; @@ -190,14 +199,14 @@ abstract public function deletePaymentMethod(string $paymentMethodId): bool; * @param string $email * @param array $address * @param string|null $paymentMethod - * @return array + * @return Customer */ - abstract public function createCustomer(string $name, string $email, array $address = [], ?string $paymentMethod = null): array; + abstract public function createCustomer(string $name, string $email, array $address = [], ?string $paymentMethod = null): Customer; /** * List customers * - * @return array + * @return array */ abstract public function listCustomers(): array; @@ -205,9 +214,9 @@ abstract public function listCustomers(): array; * Get customer details by ID * * @param string $customerId - * @return array + * @return Customer */ - abstract public function getCustomer(string $customerId): array; + abstract public function getCustomer(string $customerId): Customer; /** * Update customer details @@ -217,9 +226,9 @@ abstract public function getCustomer(string $customerId): array; * @param string $email * @param Address|null $address * @param string|null $paymentMethod - * @return array + * @return Customer */ - abstract public function updateCustomer(string $customerId, string $name, string $email, ?Address $address = null, ?string $paymentMethod = null): array; + abstract public function updateCustomer(string $customerId, string $name, string $email, ?Address $address = null, ?string $paymentMethod = null): Customer; /** * Delete Customer @@ -234,9 +243,9 @@ abstract public function deleteCustomer(string $customerId): bool; * * @param string $customerId * @param string $paymentMethodId - * @return array + * @return PaymentMethod */ - abstract public function getPaymentMethod(string $customerId, string $paymentMethodId): array; + abstract public function getPaymentMethod(string $customerId, string $paymentMethodId): PaymentMethod; /** * Create setup for accepting future payments @@ -246,16 +255,16 @@ abstract public function getPaymentMethod(string $customerId, string $paymentMet * @param array $paymentMethodTypes * @param array $paymentMethodOptions * @param ?string $paymentMethodConfiguration - * @return array + * @return SetupIntent */ - abstract public function createFuturePayment(string $customerId, ?string $paymentMethod = null, array $paymentMethodTypes = [], array $paymentMethodOptions = [], ?string $paymentMethodConfiguration = null): array; + abstract public function createFuturePayment(string $customerId, ?string $paymentMethod = null, array $paymentMethodTypes = [], array $paymentMethodOptions = [], ?string $paymentMethodConfiguration = null): SetupIntent; /** * List future payments associated with the provided customer or payment method * * @param string|null $customerId * @param string|null $paymentMethodId - * @return array + * @return array */ abstract public function listFuturePayments(?string $customerId = null, ?string $paymentMethodId = null): array; @@ -263,9 +272,9 @@ abstract public function listFuturePayments(?string $customerId = null, ?string * Get Future payment * * @param string $id - * @return array + * @return SetupIntent */ - abstract public function getFuturePayment(string $id): array; + abstract public function getFuturePayment(string $id): SetupIntent; /** * Update future payment setup @@ -275,17 +284,17 @@ abstract public function getFuturePayment(string $id): array; * @param string|null $paymentMethod * @param array $paymentMethodOptions * @param string|null $paymentMethodConfiguration - * @return array + * @return SetupIntent */ - abstract public function updateFuturePayment(string $id, ?string $customerId = null, ?string $paymentMethod = null, array $paymentMethodOptions = [], ?string $paymentMethodConfiguration = null): array; + abstract public function updateFuturePayment(string $id, ?string $customerId = null, ?string $paymentMethod = null, array $paymentMethodOptions = [], ?string $paymentMethodConfiguration = null): SetupIntent; /** * Get mandate * * @param string $id - * @return array + * @return Mandate */ - abstract public function getMandate(string $id): array; + abstract public function getMandate(string $id): Mandate; /** * List disputes @@ -294,7 +303,24 @@ abstract public function getMandate(string $id): array; * @param string|null $paymentIntentId * @param string|null $chargeId * @param int|null $createdAfter - * @return array + * @return array */ abstract public function listDisputes(?int $limit = null, ?string $paymentIntentId = null, ?string $chargeId = null, ?int $createdAfter = null): array; + + /** + * Verify a webhook signature and decode the event + * + * @param string $payload Raw request body, exactly as received + * @param string $signatureHeader + * @param string $secret + * @param int|null $tolerance Maximum age of the signature in seconds, null to skip the check + * @return WebhookEvent + * + * @throws Exception + */ + public function constructWebhookEvent(string $payload, string $signatureHeader, string $secret, ?int $tolerance = 300): WebhookEvent + { + // Not abstract so adding it does not break third-party adapters + throw new Exception(Exception::GENERAL_UNKNOWN, $this->getName().' does not support webhooks'); + } } diff --git a/src/Pay/Adapter/Stripe.php b/src/Pay/Adapter/Stripe.php index 7cd5f1a..4af433d 100644 --- a/src/Pay/Adapter/Stripe.php +++ b/src/Pay/Adapter/Stripe.php @@ -8,7 +8,16 @@ use Utopia\Client\Adapter\Curl\Client as Curl; use Utopia\Pay\Adapter; use Utopia\Pay\Address; +use Utopia\Pay\Customer\Customer; +use Utopia\Pay\Dispute\Dispute; use Utopia\Pay\Exception; +use Utopia\Pay\Mandate\Mandate; +use Utopia\Pay\Payment\Payment; +use Utopia\Pay\PaymentMethod\PaymentMethod; +use Utopia\Pay\Refund\Refund; +use Utopia\Pay\SetupIntent\SetupIntent; +use Utopia\Pay\Validator\Stripe\Webhook; +use Utopia\Pay\Webhook\WebhookEvent; use Utopia\Psr7\ContentType; use Utopia\Psr7\Header; use Utopia\Psr7\Method; @@ -54,7 +63,7 @@ public function getName(): string /** * Make a purchase request */ - public function purchase(int $amount, string $customerId, ?string $paymentMethodId = null, array $additionalParams = []): array + public function purchase(int $amount, string $customerId, ?string $paymentMethodId = null, array $additionalParams = []): Payment { $path = '/payment_intents'; $requestBody = [ @@ -69,14 +78,14 @@ public function purchase(int $amount, string $customerId, ?string $paymentMethod $requestBody = array_merge($requestBody, $additionalParams); $result = $this->execute(Method::POST, $path, $requestBody); - return $result; + return Payment::fromArray($result); } /** * Authorize a payment (hold funds without capturing) * Creates a payment intent with capture_method set to manual */ - public function authorize(int $amount, string $customerId, ?string $paymentMethodId = null, array $additionalParams = []): array + public function authorize(int $amount, string $customerId, ?string $paymentMethodId = null, array $additionalParams = []): Payment { $path = '/payment_intents'; $requestBody = [ @@ -92,13 +101,13 @@ public function authorize(int $amount, string $customerId, ?string $paymentMetho $requestBody = array_merge($requestBody, $additionalParams); $result = $this->execute(Method::POST, $path, $requestBody); - return $result; + return Payment::fromArray($result); } /** * Capture a previously authorized payment */ - public function capture(string $paymentId, ?int $amount = null, array $additionalParams = []): array + public function capture(string $paymentId, ?int $amount = null, array $additionalParams = []): Payment { $path = '/payment_intents/'.$paymentId.'/capture'; $requestBody = []; @@ -110,18 +119,18 @@ public function capture(string $paymentId, ?int $amount = null, array $additiona $requestBody = array_merge($requestBody, $additionalParams); $result = $this->execute(Method::POST, $path, $requestBody); - return $result; + return Payment::fromArray($result); } /** * Cancel/void a payment authorization */ - public function cancelAuthorization(string $paymentId, array $additionalParams = []): array + public function cancelAuthorization(string $paymentId, array $additionalParams = []): Payment { $path = '/payment_intents/'.$paymentId.'/cancel'; $result = $this->execute(Method::POST, $path, $additionalParams); - return $result; + return Payment::fromArray($result); } /** @@ -130,9 +139,9 @@ public function cancelAuthorization(string $paymentId, array $additionalParams = * @param string $paymentId The payment intent ID to retry * @param string|null $paymentMethodId The payment method to use (optional) * @param array $additionalParams Additional parameters for the retry (optional) - * @return array The result of the retry attempt + * @return Payment The result of the retry attempt */ - public function retryPurchase(string $paymentId, ?string $paymentMethodId = null, array $additionalParams = []): array + public function retryPurchase(string $paymentId, ?string $paymentMethodId = null, array $additionalParams = []): Payment { $path = '/payment_intents/'.$paymentId.'/confirm'; $requestBody = []; @@ -145,13 +154,13 @@ public function retryPurchase(string $paymentId, ?string $paymentMethodId = null $requestBody = array_merge($requestBody, $additionalParams); $result = $this->execute(Method::POST, $path, $requestBody); - return $result; + return Payment::fromArray($result); } /** * Refund payment */ - public function refund(string $paymentId, ?int $amount = null, ?string $reason = null): array + public function refund(string $paymentId, ?int $amount = null, ?string $reason = null): Refund { $path = '/refunds'; $requestBody = ['payment_intent' => $paymentId]; @@ -163,20 +172,20 @@ public function refund(string $paymentId, ?int $amount = null, ?string $reason = $requestBody['reason'] = $reason; } - return $this->execute(Method::POST, $path, $requestBody); + return Refund::fromArray($this->execute(Method::POST, $path, $requestBody)); } /** * Get a payment details * * @param string $paymentId - * @return array + * @return Payment */ - public function getPayment(string $paymentId): array + public function getPayment(string $paymentId): Payment { $path = '/payment_intents/'.$paymentId; - return $this->execute(Method::GET, $path); + return Payment::fromArray($this->execute(Method::GET, $path)); } /** @@ -187,9 +196,9 @@ public function getPayment(string $paymentId): array * @param int|null $amount Amount to update (optional) * @param string|null $currency Currency to update (optional) * @param array $additionalParams Additional parameters (optional) - * @return array Result of the update + * @return Payment Result of the update */ - public function updatePayment(string $paymentId, ?string $paymentMethodId = null, ?int $amount = null, ?string $currency = null, array $additionalParams = []): array + public function updatePayment(string $paymentId, ?string $paymentMethodId = null, ?int $amount = null, ?string $currency = null, array $additionalParams = []): Payment { $path = '/payment_intents/'.$paymentId; $requestBody = []; @@ -206,13 +215,13 @@ public function updatePayment(string $paymentId, ?string $paymentMethodId = null $requestBody = array_merge($requestBody, $additionalParams); - return $this->execute(Method::POST, $path, $requestBody); + return Payment::fromArray($this->execute(Method::POST, $path, $requestBody)); } /** * Add a credit card for customer */ - public function createPaymentMethod(string $customerId, string $type, array $paymentMethodDetails): array + public function createPaymentMethod(string $customerId, string $type, array $paymentMethodDetails): PaymentMethod { $path = '/payment_methods'; @@ -228,27 +237,31 @@ public function createPaymentMethod(string $customerId, string $type, array $pay // attach payment method to the customer $path .= '/'.$paymentMethodId.'/attach'; - return $this->execute(Method::POST, $path, ['customer' => $customerId]); + return PaymentMethod::fromArray($this->execute(Method::POST, $path, ['customer' => $customerId])); } /** * List cards + * + * @return array */ public function listPaymentMethods(string $customerId): array { $path = '/customers/'.$customerId.'/payment_methods'; - return $this->execute(Method::GET, $path); + $result = $this->execute(Method::GET, $path); + + return array_map(fn (array $item) => PaymentMethod::fromArray($item), $result['data'] ?? []); } /** * List Customer Payment Methods */ - public function getPaymentMethod(string $customerId, string $paymentMethodId): array + public function getPaymentMethod(string $customerId, string $paymentMethodId): PaymentMethod { $path = '/customers/'.$customerId.'/payment_methods/'.$paymentMethodId; - return $this->execute(Method::GET, $path); + return PaymentMethod::fromArray($this->execute(Method::GET, $path)); } /** @@ -259,9 +272,9 @@ public function getPaymentMethod(string $customerId, string $paymentMethodId): a * @param string|null $email * @param string|null $phone * @param array|null $address - * @return array + * @return PaymentMethod */ - public function updatePaymentMethodBillingDetails(string $paymentMethodId, ?string $name = null, ?string $email = null, ?string $phone = null, ?array $address = null): array + public function updatePaymentMethodBillingDetails(string $paymentMethodId, ?string $name = null, ?string $email = null, ?string $phone = null, ?array $address = null): PaymentMethod { $path = '/payment_methods/'.$paymentMethodId; $requestBody = []; @@ -279,10 +292,10 @@ public function updatePaymentMethodBillingDetails(string $paymentMethodId, ?stri $requestBody['billing_details']['address'] = $address; } - return $this->execute(Method::POST, $path, $requestBody); + return PaymentMethod::fromArray($this->execute(Method::POST, $path, $requestBody)); } - public function updatePaymentMethod(string $paymentMethodId, string $type, array $details): array + public function updatePaymentMethod(string $paymentMethodId, string $type, array $details): PaymentMethod { $path = '/payment_methods/'.$paymentMethodId; @@ -290,7 +303,7 @@ public function updatePaymentMethod(string $paymentMethodId, string $type, array $type => $details, ]; - return $this->execute(Method::POST, $path, $requestBody); + return PaymentMethod::fromArray($this->execute(Method::POST, $path, $requestBody)); } /** @@ -310,7 +323,7 @@ public function deletePaymentMethod(string $paymentMethodId): bool * * @throws \Exception */ - public function createCustomer(string $name, string $email, array $address = [], ?string $paymentMethod = null): array + public function createCustomer(string $name, string $email, array $address = [], ?string $paymentMethod = null): Customer { $path = '/customers'; $requestBody = [ @@ -325,32 +338,36 @@ public function createCustomer(string $name, string $email, array $address = [], } $result = $this->execute(Method::POST, $path, $requestBody); - return $result; + return Customer::fromArray($result); } /** * List customers + * + * @return array */ public function listCustomers(): array { - return $this->execute(Method::GET, '/customers'); + $result = $this->execute(Method::GET, '/customers'); + + return array_map(fn (array $item) => Customer::fromArray($item), $result['data'] ?? []); } /** * Get customer details by ID */ - public function getCustomer(string $customerId): array + public function getCustomer(string $customerId): Customer { $path = '/customers/'.$customerId; $result = $this->execute(Method::GET, $path); - return $result; + return Customer::fromArray($result); } /** * Update customer details */ - public function updateCustomer(string $customerId, string $name, string $email, ?Address $address = null, ?string $paymentMethod = null): array + public function updateCustomer(string $customerId, string $name, string $email, ?Address $address = null, ?string $paymentMethod = null): Customer { $path = '/customers/'.$customerId; $requestBody = [ @@ -364,7 +381,7 @@ public function updateCustomer(string $customerId, string $name, string $email, $requestBody['address'] = $address->asArray(); } - return $this->execute(Method::POST, $path, $requestBody); + return Customer::fromArray($this->execute(Method::POST, $path, $requestBody)); } /** @@ -378,7 +395,7 @@ public function deleteCustomer(string $customerId): bool return $result['deleted'] ?? false; } - public function createFuturePayment(string $customerId, ?string $paymentMethod = null, array $paymentMethodTypes = ['card'], array $paymentMethodOptions = [], ?string $paymentMethodConfiguration = null): array + public function createFuturePayment(string $customerId, ?string $paymentMethod = null, array $paymentMethodTypes = ['card'], array $paymentMethodOptions = [], ?string $paymentMethodConfiguration = null): SetupIntent { $path = '/setup_intents'; $requestBody = [ @@ -404,14 +421,14 @@ public function createFuturePayment(string $customerId, ?string $paymentMethod = $result = $this->execute(Method::POST, $path, $requestBody); - return $result; + return SetupIntent::fromArray($result); } - public function getFuturePayment(string $id): array + public function getFuturePayment(string $id): SetupIntent { $path = '/setup_intents/'.$id; - return $this->execute(Method::GET, $path); + return SetupIntent::fromArray($this->execute(Method::GET, $path)); } public function listFuturePayments(?string $customerId = null, ?string $pyamentMethodId = null): array @@ -427,10 +444,10 @@ public function listFuturePayments(?string $customerId = null, ?string $pyamentM } $result = $this->execute(Method::GET, $path, $requestBody); - return $result['data']; + return array_map(fn (array $item) => SetupIntent::fromArray($item), $result['data'] ?? []); } - public function updateFuturePayment(string $id, ?string $customerId = null, ?string $paymentMethod = null, array $paymentMethodOptions = [], ?string $paymentMethodConfiguration = null): array + public function updateFuturePayment(string $id, ?string $customerId = null, ?string $paymentMethod = null, array $paymentMethodOptions = [], ?string $paymentMethodConfiguration = null): SetupIntent { $path = '/setup_intents/'.$id; $requestBody = []; @@ -447,20 +464,20 @@ public function updateFuturePayment(string $id, ?string $customerId = null, ?str $requestBody['payment_method_options'] = $paymentMethodOptions; } - return $this->execute(Method::POST, $path, $requestBody); + return SetupIntent::fromArray($this->execute(Method::POST, $path, $requestBody)); } /** * Get mandate * * @param string $id - * @return array + * @return Mandate */ - public function getMandate(string $id): array + public function getMandate(string $id): Mandate { $path = '/mandates/'.$id; - return $this->execute(Method::GET, $path); + return Mandate::fromArray($this->execute(Method::GET, $path)); } /** @@ -470,7 +487,7 @@ public function getMandate(string $id): array * @param string|null $paymentIntentId * @param string|null $chargeId * @param int|null $createdAfter - * @return array + * @return array */ public function listDisputes(?int $limit = null, ?string $paymentIntentId = null, ?string $chargeId = null, ?int $createdAfter = null): array { @@ -495,7 +512,28 @@ public function listDisputes(?int $limit = null, ?string $paymentIntentId = null $result = $this->execute(Method::GET, $path, $requestBody); - return $result['data']; + return array_map(fn (array $item) => Dispute::fromArray($item), $result['data'] ?? []); + } + + /** + * Verify a `Stripe-Signature` header and decode the event + * + * @return WebhookEvent + * + * @throws Exception + */ + public function constructWebhookEvent(string $payload, string $signatureHeader, string $secret, ?int $tolerance = Webhook::DEFAULT_TOLERANCE): WebhookEvent + { + if (! (new Webhook())->isValid($payload, $signatureHeader, $secret, $tolerance)) { + throw new Exception(Exception::SIGNATURE_VERIFICATION_FAILED, 'Invalid webhook signature', 400); + } + + $event = json_decode($payload, true); + if (! is_array($event)) { + throw new Exception(Exception::GENERAL_UNKNOWN, 'Invalid webhook payload', 400); + } + + return WebhookEvent::fromArray($event); } /** @@ -559,6 +597,8 @@ protected function handleError(int $code, mixed $response) throw new Exception($type, $message, $code, $error); } - throw new Exception($response, $code); + // Transport failures and non-JSON bodies (e.g. a proxy error page) arrive as strings + $message = is_string($response) && $response !== '' ? $response : 'Unknown error'; + throw new Exception(Exception::GENERAL_UNKNOWN, $message, $code); } } diff --git a/src/Pay/Address.php b/src/Pay/Address.php index 9fb3fb5..a72f779 100644 --- a/src/Pay/Address.php +++ b/src/Pay/Address.php @@ -211,4 +211,22 @@ public function asArray(): array 'state' => $this->state ?? null, ]; } + + /** + * Create from the snake_case shape produced by asArray() + * + * @param array $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + city: (string) ($data['city'] ?? ''), + country: (string) ($data['country'] ?? ''), + line1: $data['line1'] ?? null, + line2: $data['line2'] ?? null, + postalCode: $data['postal_code'] ?? null, + state: $data['state'] ?? null, + ); + } } diff --git a/src/Pay/Charge/Charge.php b/src/Pay/Charge/Charge.php new file mode 100644 index 0000000..eff9fe2 --- /dev/null +++ b/src/Pay/Charge/Charge.php @@ -0,0 +1,44 @@ +string('id'); + } + + /** + * Amount in the smallest currency unit + */ + public function getAmount(): ?int + { + return $this->int('amount'); + } + + public function getAmountRefunded(): ?int + { + return $this->int('amount_refunded'); + } + + public function getCurrency(): ?string + { + return $this->string('currency'); + } + + public function getStatus(): ?string + { + return $this->string('status'); + } + + public function isRefunded(): bool + { + return $this->bool('refunded') === true; + } +} diff --git a/src/Pay/Customer/Customer.php b/src/Pay/Customer/Customer.php new file mode 100644 index 0000000..b900b80 --- /dev/null +++ b/src/Pay/Customer/Customer.php @@ -0,0 +1,57 @@ +string('id'); + } + + public function getName(): ?string + { + return $this->string('name'); + } + + public function getEmail(): ?string + { + return $this->string('email'); + } + + public function getPhone(): ?string + { + return $this->string('phone'); + } + + public function getDefaultPaymentMethodId(): ?string + { + return $this->expandableId('invoice_settings', 'default_payment_method'); + } + + /** + * @return array + */ + public function getMetadata(): array + { + return $this->array('metadata') ?? []; + } + + public function getCreatedAt(): ?int + { + return $this->int('created'); + } + + /** + * getCustomer() still answers for a deleted customer, with only `id` and `deleted` set + */ + public function isDeleted(): bool + { + return $this->bool('deleted') === true; + } +} diff --git a/src/Pay/Dispute/Dispute.php b/src/Pay/Dispute/Dispute.php new file mode 100644 index 0000000..18d1e6a --- /dev/null +++ b/src/Pay/Dispute/Dispute.php @@ -0,0 +1,84 @@ +string('id'); + } + + /** + * Amount in the smallest currency unit + */ + public function getAmount(): ?int + { + return $this->int('amount'); + } + + public function getCurrency(): ?string + { + return $this->string('currency'); + } + + public function getReason(): ?string + { + return $this->string('reason'); + } + + public function getStatus(): ?string + { + return $this->string('status'); + } + + public function getChargeId(): ?string + { + return $this->expandableId('charge'); + } + + public function getPaymentIntentId(): ?string + { + return $this->expandableId('payment_intent'); + } + + /** + * Metadata of the payment intent, only available when `payment_intent` is expanded + * + * @return array|null + */ + public function getPaymentIntentMetadata(): ?array + { + if ($this->array('payment_intent') === null) { + return null; + } + + return $this->array('payment_intent', 'metadata') ?? []; + } + + /** + * @return array + */ + public function getMetadata(): array + { + return $this->array('metadata') ?? []; + } + + /** + * Unix timestamp by which evidence must be submitted + */ + public function getEvidenceDueBy(): ?int + { + return $this->int('evidence_details', 'due_by'); + } + + public function getCreatedAt(): ?int + { + return $this->int('created'); + } +} diff --git a/src/Pay/Exception.php b/src/Pay/Exception.php index 074b358..3c18e76 100644 --- a/src/Pay/Exception.php +++ b/src/Pay/Exception.php @@ -14,6 +14,32 @@ class Exception extends \Exception public const GENERIC_DECLINE = 'generic_decline'; + public const CARD_DECLINED = 'card_declined'; + + public const EXPIRED_CARD = 'expired_card'; + + public const INCORRECT_CVC = 'incorrect_cvc'; + + public const LOST_CARD = 'lost_card'; + + public const STOLEN_CARD = 'stolen_card'; + + public const FRAUDULENT = 'fraudulent'; + + public const DO_NOT_HONOR = 'do_not_honor'; + + public const PROCESSING_ERROR = 'processing_error'; + + public const AMOUNT_TOO_SMALL = 'amount_too_small'; + + public const PAYMENT_INTENT_UNEXPECTED_STATE = 'payment_intent_unexpected_state'; + + public const RESOURCE_MISSING = 'resource_missing'; + + public const RATE_LIMIT = 'rate_limit'; + + public const SIGNATURE_VERIFICATION_FAILED = 'signature_verification_failed'; + protected string $type = ''; /** diff --git a/src/Pay/Mandate/Mandate.php b/src/Pay/Mandate/Mandate.php new file mode 100644 index 0000000..ec18fa8 --- /dev/null +++ b/src/Pay/Mandate/Mandate.php @@ -0,0 +1,33 @@ +string('id'); + } + + public function getStatus(): ?string + { + return $this->string('status'); + } + + public function getPaymentMethodId(): ?string + { + return $this->expandableId('payment_method'); + } + + public function isActive(): bool + { + return $this->getStatus() === self::STATUS_ACTIVE; + } +} diff --git a/src/Pay/Model.php b/src/Pay/Model.php new file mode 100644 index 0000000..7c15cb0 --- /dev/null +++ b/src/Pay/Model.php @@ -0,0 +1,107 @@ + $data + */ + final public function __construct(private readonly array $data) + { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): static + { + return new static($data); + } + + /** + * The payload as the gateway returned it, for fields that have no getter + * + * @return array + */ + public function getRaw(): array + { + return $this->data; + } + + protected function value(string ...$path): mixed + { + $value = $this->data; + foreach ($path as $key) { + if (! is_array($value) || ! array_key_exists($key, $value)) { + return null; + } + $value = $value[$key]; + } + + return $value; + } + + protected function string(string ...$path): ?string + { + $value = $this->value(...$path); + + return is_string($value) ? $value : null; + } + + protected function int(string ...$path): ?int + { + $value = $this->value(...$path); + + return is_int($value) ? $value : null; + } + + protected function bool(string ...$path): ?bool + { + $value = $this->value(...$path); + + return is_bool($value) ? $value : null; + } + + /** + * @return array|null + */ + protected function array(string ...$path): ?array + { + $value = $this->value(...$path); + + return is_array($value) ? $value : null; + } + + /** + * Related objects come back as an ID, or as the full object when expanded + */ + protected function expandableId(string ...$path): ?string + { + $value = $this->value(...$path); + if (is_array($value)) { + $value = $value['id'] ?? null; + } + + return is_string($value) ? $value : null; + } + + /** + * Stripe list objects keep their items under `data` + * + * @template T of Model + * + * @param class-string $class + * @return array + */ + protected function list(string $class, string ...$path): array + { + $items = $this->array(...[...$path, 'data']) ?? []; + + return array_values(array_map(fn ($item) => $class::fromArray(is_array($item) ? $item : []), $items)); + } +} diff --git a/src/Pay/Pay.php b/src/Pay/Pay.php index fdea6c0..45b6f1e 100644 --- a/src/Pay/Pay.php +++ b/src/Pay/Pay.php @@ -2,6 +2,15 @@ namespace Utopia\Pay; +use Utopia\Pay\Customer\Customer; +use Utopia\Pay\Dispute\Dispute; +use Utopia\Pay\Mandate\Mandate; +use Utopia\Pay\Payment\Payment; +use Utopia\Pay\PaymentMethod\PaymentMethod; +use Utopia\Pay\Refund\Refund; +use Utopia\Pay\SetupIntent\SetupIntent; +use Utopia\Pay\Webhook\WebhookEvent; + class Pay { /** @@ -79,9 +88,9 @@ public function getCurrency(): string * @param string $customerId * @param string|null $paymentMethodId * @param array $additionalParams - * @return array + * @return Payment */ - public function purchase(int $amount, string $customerId, ?string $paymentMethodId = null, array $additionalParams = []): array + public function purchase(int $amount, string $customerId, ?string $paymentMethodId = null, array $additionalParams = []): Payment { return $this->adapter->purchase($amount, $customerId, $paymentMethodId, $additionalParams); } @@ -96,9 +105,9 @@ public function purchase(int $amount, string $customerId, ?string $paymentMethod * @param string $customerId * @param string|null $paymentMethodId * @param array $additionalParams - * @return array + * @return Payment */ - public function authorize(int $amount, string $customerId, ?string $paymentMethodId = null, array $additionalParams = []): array + public function authorize(int $amount, string $customerId, ?string $paymentMethodId = null, array $additionalParams = []): Payment { return $this->adapter->authorize($amount, $customerId, $paymentMethodId, $additionalParams); } @@ -111,9 +120,9 @@ public function authorize(int $amount, string $customerId, ?string $paymentMetho * @param string $paymentId * @param int|null $amount * @param array $additionalParams - * @return array + * @return Payment */ - public function capture(string $paymentId, ?int $amount = null, array $additionalParams = []): array + public function capture(string $paymentId, ?int $amount = null, array $additionalParams = []): Payment { return $this->adapter->capture($paymentId, $amount, $additionalParams); } @@ -125,9 +134,9 @@ public function capture(string $paymentId, ?int $amount = null, array $additiona * * @param string $paymentId * @param array $additionalParams - * @return array + * @return Payment */ - public function cancelAuthorization(string $paymentId, array $additionalParams = []): array + public function cancelAuthorization(string $paymentId, array $additionalParams = []): Payment { return $this->adapter->cancelAuthorization($paymentId, $additionalParams); } @@ -138,9 +147,9 @@ public function cancelAuthorization(string $paymentId, array $additionalParams = * @param string $paymentId The payment intent ID to retry * @param string|null $paymentMethodId The payment method to use (optional) * @param array $additionalParams Additional parameters for the retry (optional) - * @return array The result of the retry attempt + * @return Payment The result of the retry attempt */ - public function retryPurchase(string $paymentId, ?string $paymentMethodId = null, array $additionalParams = []): array + public function retryPurchase(string $paymentId, ?string $paymentMethodId = null, array $additionalParams = []): Payment { return $this->adapter->retryPurchase($paymentId, $paymentMethodId, $additionalParams); } @@ -150,9 +159,9 @@ public function retryPurchase(string $paymentId, ?string $paymentMethodId = null * * @param string $paymentId * @param int $amount - * @return array + * @return Refund */ - public function refund(string $paymentId, int $amount): array + public function refund(string $paymentId, int $amount): Refund { return $this->adapter->refund($paymentId, $amount); } @@ -161,9 +170,9 @@ public function refund(string $paymentId, int $amount): array * Get a payment details * * @param string $paymentId - * @return array + * @return Payment */ - public function getPayment(string $paymentId): array + public function getPayment(string $paymentId): Payment { return $this->adapter->getPayment($paymentId); } @@ -176,9 +185,9 @@ public function getPayment(string $paymentId): array * @param int|null $amount Amount to update (optional) * @param string|null $currency Currency to update (optional) * @param array $additionalParams Additional parameters (optional) - * @return array Result of the update + * @return Payment Result of the update */ - public function updatePayment(string $paymentId, ?string $paymentMethodId = null, ?int $amount = null, ?string $currency = null, array $additionalParams = []): array + public function updatePayment(string $paymentId, ?string $paymentMethodId = null, ?int $amount = null, ?string $currency = null, array $additionalParams = []): Payment { return $this->adapter->updatePayment($paymentId, $paymentMethodId, $amount, $currency, $additionalParams); } @@ -200,9 +209,9 @@ public function deletePaymentMethod(string $paymentMethodId): bool * @param string $customerId * @param string $type * @param array $details - * @return array + * @return PaymentMethod */ - public function createPaymentMethod(string $customerId, string $type, array $details): array + public function createPaymentMethod(string $customerId, string $type, array $details): PaymentMethod { return $this->adapter->createPaymentMethod($customerId, $type, $details); } @@ -216,9 +225,9 @@ public function createPaymentMethod(string $customerId, string $type, array $det * @param string $email * @param string $phone * @param array $address - * @return array + * @return PaymentMethod */ - public function updatePaymentMethodBillingDetails(string $paymentMethodId, string $type, ?string $name = null, ?string $email = null, ?string $phone = null, ?array $address = null): array + public function updatePaymentMethodBillingDetails(string $paymentMethodId, string $type, ?string $name = null, ?string $email = null, ?string $phone = null, ?array $address = null): PaymentMethod { return $this->adapter->updatePaymentMethodBillingDetails($paymentMethodId, $name, $email, $phone, $address); } @@ -229,9 +238,9 @@ public function updatePaymentMethodBillingDetails(string $paymentMethodId, strin * @param string $paymentMethodId * @param string $type * @param array $details - * @return array + * @return PaymentMethod */ - public function updatePaymentMethod(string $paymentMethodId, string $type, array $details): array + public function updatePaymentMethod(string $paymentMethodId, string $type, array $details): PaymentMethod { return $this->adapter->updatePaymentMethod($paymentMethodId, $type, $details); } @@ -241,9 +250,9 @@ public function updatePaymentMethod(string $paymentMethodId, string $type, array * * @param string $customerId * @param string $paymentMethodId - * @return array + * @return PaymentMethod */ - public function getPaymentMethod(string $customerId, string $paymentMethodId): array + public function getPaymentMethod(string $customerId, string $paymentMethodId): PaymentMethod { return $this->adapter->getPaymentMethod($customerId, $paymentMethodId); } @@ -252,7 +261,7 @@ public function getPaymentMethod(string $customerId, string $paymentMethodId): a * List Payment Methods * * @param string $customerId - * @return array + * @return array */ public function listPaymentMethods(string $customerId): array { @@ -262,7 +271,7 @@ public function listPaymentMethods(string $customerId): array /** * List Customers * - * @return array + * @return array */ public function listCustomers(): array { @@ -279,9 +288,9 @@ public function listCustomers(): array * @param string $email * @param array $address * @param string|null $paymentMethod - * @return array + * @return Customer */ - public function createCustomer(string $name, string $email, array $address = [], ?string $paymentMethod = null): array + public function createCustomer(string $name, string $email, array $address = [], ?string $paymentMethod = null): Customer { return $this->adapter->createCustomer($name, $email, $address, $paymentMethod); } @@ -290,9 +299,9 @@ public function createCustomer(string $name, string $email, array $address = [], * Get Customer * * @param string $customerId - * @return array + * @return Customer */ - public function getCustomer(string $customerId): array + public function getCustomer(string $customerId): Customer { return $this->adapter->getCustomer($customerId); } @@ -305,9 +314,9 @@ public function getCustomer(string $customerId): array * @param string $email * @param string $paymentMethod * @param Address $address - * @return array + * @return Customer */ - public function updateCustomer(string $customerId, string $name, string $email, ?Address $address = null, ?string $paymentMethod = null): array + public function updateCustomer(string $customerId, string $name, string $email, ?Address $address = null, ?string $paymentMethod = null): Customer { return $this->adapter->updateCustomer($customerId, $name, $email, $address, $paymentMethod); } @@ -331,9 +340,9 @@ public function deleteCustomer(string $customerId): bool * @param array $paymentMethodTypes * @param array $paymentMethodOptions * @param string $paymentMethodConfiguration - * @return array + * @return SetupIntent */ - public function createFuturePayment(string $customerId, ?string $paymentMethod = null, array $paymentMethodTypes = ['card'], array $paymentMethodOptions = [], ?string $paymentMethodConfiguration = null): array + public function createFuturePayment(string $customerId, ?string $paymentMethod = null, array $paymentMethodTypes = ['card'], array $paymentMethodOptions = [], ?string $paymentMethodConfiguration = null): SetupIntent { return $this->adapter->createFuturePayment($customerId, $paymentMethod, $paymentMethodTypes, $paymentMethodOptions, $paymentMethodConfiguration); } @@ -342,9 +351,9 @@ public function createFuturePayment(string $customerId, ?string $paymentMethod = * Get future payment * * @param string $id - * @return array + * @return SetupIntent */ - public function getFuturePayment(string $id): array + public function getFuturePayment(string $id): SetupIntent { return $this->adapter->getFuturePayment($id); } @@ -357,9 +366,9 @@ public function getFuturePayment(string $id): array * @param string|null $paymentMethod * @param array $paymentMethodOptions * @param string|null $paymentMethodConfiguration - * @return array + * @return SetupIntent */ - public function updateFuturePayment(string $id, ?string $customerId = null, ?string $paymentMethod = null, array $paymentMethodOptions = [], ?string $paymentMethodConfiguration = null): array + public function updateFuturePayment(string $id, ?string $customerId = null, ?string $paymentMethod = null, array $paymentMethodOptions = [], ?string $paymentMethodConfiguration = null): SetupIntent { return $this->adapter->updateFuturePayment($id, $customerId, $paymentMethod, $paymentMethodOptions, $paymentMethodConfiguration); } @@ -369,7 +378,7 @@ public function updateFuturePayment(string $id, ?string $customerId = null, ?str * * @param string|null $customerId * @param string|null $paymentMethodId - * @return array + * @return array */ public function listFuturePayment(?string $customerId, ?string $paymentMethodId = null): array { @@ -380,9 +389,9 @@ public function listFuturePayment(?string $customerId, ?string $paymentMethodId * Get mandate * * @param string $id - * @return array + * @return Mandate */ - public function getMandate(string $id): array + public function getMandate(string $id): Mandate { return $this->adapter->getMandate($id); } @@ -394,10 +403,26 @@ public function getMandate(string $id): array * @param string|null $paymentIntentId * @param string|null $chargeId * @param int|null $createdAfter - * @return array + * @return array */ public function listDisputes(?int $limit = null, ?string $paymentIntentId = null, ?string $chargeId = null, ?int $createdAfter = null): array { return $this->adapter->listDisputes($limit, $paymentIntentId, $chargeId, $createdAfter); } + + /** + * Verify a webhook signature and decode the event + * + * @param string $payload Raw request body, exactly as received + * @param string $signatureHeader + * @param string $secret + * @param int|null $tolerance Maximum age of the signature in seconds, null to skip the check + * @return WebhookEvent + * + * @throws Exception + */ + public function constructWebhookEvent(string $payload, string $signatureHeader, string $secret, ?int $tolerance = 300): WebhookEvent + { + return $this->adapter->constructWebhookEvent($payload, $signatureHeader, $secret, $tolerance); + } } diff --git a/src/Pay/Payment/Payment.php b/src/Pay/Payment/Payment.php new file mode 100644 index 0000000..d9b560d --- /dev/null +++ b/src/Pay/Payment/Payment.php @@ -0,0 +1,155 @@ +string('id'); + } + + /** + * Amount in the smallest currency unit + */ + public function getAmount(): ?int + { + return $this->int('amount'); + } + + public function getAmountReceived(): ?int + { + return $this->int('amount_received'); + } + + public function getCurrency(): ?string + { + return $this->string('currency'); + } + + public function getStatus(): ?string + { + return $this->string('status'); + } + + public function getCustomerId(): ?string + { + return $this->expandableId('customer'); + } + + public function getPaymentMethodId(): ?string + { + return $this->expandableId('payment_method'); + } + + public function getClientSecret(): ?string + { + return $this->string('client_secret'); + } + + public function getLatestChargeId(): ?string + { + return $this->expandableId('latest_charge'); + } + + /** + * Charges from the legacy `charges` list, empty on API versions that only send `latest_charge` + * + * @return array + */ + public function getCharges(): array + { + return $this->list(Charge::class, 'charges'); + } + + /** + * `last_payment_error.code` of the last failed attempt + */ + public function getErrorCode(): ?string + { + return $this->string('last_payment_error', 'code'); + } + + public function getDeclineCode(): ?string + { + return $this->string('last_payment_error', 'decline_code'); + } + + public function getErrorMessage(): ?string + { + return $this->string('last_payment_error', 'message'); + } + + /** + * Free-form instructions for completing authentication, e.g. `use_stripe_sdk` + * + * @return array|null + */ + public function getNextAction(): ?array + { + return $this->array('next_action'); + } + + /** + * @return array + */ + public function getMetadata(): array + { + return $this->array('metadata') ?? []; + } + + public function getCreatedAt(): ?int + { + return $this->int('created'); + } + + public function isSucceeded(): bool + { + return $this->getStatus() === self::STATUS_SUCCEEDED; + } + + public function isProcessing(): bool + { + return $this->getStatus() === self::STATUS_PROCESSING; + } + + public function isCanceled(): bool + { + return $this->getStatus() === self::STATUS_CANCELED; + } + + public function requiresAction(): bool + { + return $this->getStatus() === self::STATUS_REQUIRES_ACTION; + } + + public function requiresCapture(): bool + { + return $this->getStatus() === self::STATUS_REQUIRES_CAPTURE; + } + + public function requiresPaymentMethod(): bool + { + return $this->getStatus() === self::STATUS_REQUIRES_PAYMENT_METHOD; + } +} diff --git a/src/Pay/PaymentMethod/PaymentMethod.php b/src/Pay/PaymentMethod/PaymentMethod.php new file mode 100644 index 0000000..c0bda92 --- /dev/null +++ b/src/Pay/PaymentMethod/PaymentMethod.php @@ -0,0 +1,141 @@ +string('id'); + } + + public function getType(): ?string + { + return $this->string('type'); + } + + public function getCustomerId(): ?string + { + return $this->expandableId('customer'); + } + + public function getBrand(): ?string + { + return $this->string('card', 'brand'); + } + + public function getLast4(): ?string + { + return $this->string('card', 'last4'); + } + + public function getExpMonth(): ?int + { + return $this->int('card', 'exp_month'); + } + + public function getExpYear(): ?int + { + return $this->int('card', 'exp_year'); + } + + public function getFunding(): ?string + { + return $this->string('card', 'funding'); + } + + /** + * Country that issued the card + */ + public function getCountry(): ?string + { + return $this->string('card', 'country'); + } + + public function getBillingName(): ?string + { + return $this->string('billing_details', 'name'); + } + + public function getBillingEmail(): ?string + { + return $this->string('billing_details', 'email'); + } + + public function getBillingPhone(): ?string + { + return $this->string('billing_details', 'phone'); + } + + public function getBillingCity(): ?string + { + return $this->string('billing_details', 'address', 'city'); + } + + public function getBillingCountry(): ?string + { + return $this->string('billing_details', 'address', 'country'); + } + + public function getBillingLine1(): ?string + { + return $this->string('billing_details', 'address', 'line1'); + } + + public function getBillingLine2(): ?string + { + return $this->string('billing_details', 'address', 'line2'); + } + + public function getBillingPostalCode(): ?string + { + return $this->string('billing_details', 'address', 'postal_code'); + } + + public function getBillingState(): ?string + { + return $this->string('billing_details', 'address', 'state'); + } + + /** + * @return array + */ + public function getMetadata(): array + { + return $this->array('metadata') ?? []; + } + + public function getCreatedAt(): ?int + { + return $this->int('created'); + } + + public function isCard(): bool + { + return $this->getType() === self::TYPE_CARD; + } + + /** + * Cards stay valid through the last day of their expiry month. + */ + public function isExpired(?\DateTimeInterface $now = null): bool + { + $month = $this->getExpMonth(); + $year = $this->getExpYear(); + if ($month === null || $year === null) { + return false; + } + + $now ??= new \DateTimeImmutable(); + $current = (int) $now->format('Y') * 12 + (int) $now->format('n'); + + return $current > $year * 12 + $month; + } +} diff --git a/src/Pay/Refund/Refund.php b/src/Pay/Refund/Refund.php new file mode 100644 index 0000000..8bd62c4 --- /dev/null +++ b/src/Pay/Refund/Refund.php @@ -0,0 +1,74 @@ +string('id'); + } + + /** + * Amount in the smallest currency unit + */ + public function getAmount(): ?int + { + return $this->int('amount'); + } + + public function getCurrency(): ?string + { + return $this->string('currency'); + } + + public function getStatus(): ?string + { + return $this->string('status'); + } + + public function getPaymentIntentId(): ?string + { + return $this->expandableId('payment_intent'); + } + + public function getChargeId(): ?string + { + return $this->expandableId('charge'); + } + + public function getReason(): ?string + { + return $this->string('reason'); + } + + public function getFailureReason(): ?string + { + return $this->string('failure_reason'); + } + + /** + * @return array + */ + public function getMetadata(): array + { + return $this->array('metadata') ?? []; + } + + public function getCreatedAt(): ?int + { + return $this->int('created'); + } + + public function isSucceeded(): bool + { + return $this->getStatus() === self::STATUS_SUCCEEDED; + } +} diff --git a/src/Pay/SetupIntent/SetupIntent.php b/src/Pay/SetupIntent/SetupIntent.php new file mode 100644 index 0000000..092c57a --- /dev/null +++ b/src/Pay/SetupIntent/SetupIntent.php @@ -0,0 +1,58 @@ +string('id'); + } + + public function getStatus(): ?string + { + return $this->string('status'); + } + + public function getCustomerId(): ?string + { + return $this->expandableId('customer'); + } + + public function getPaymentMethodId(): ?string + { + return $this->expandableId('payment_method'); + } + + public function getClientSecret(): ?string + { + return $this->string('client_secret'); + } + + public function getMandateId(): ?string + { + return $this->expandableId('mandate'); + } + + /** + * Free-form per-type options, e.g. `card.mandate_options` + * + * @return array|null + */ + public function getPaymentMethodOptions(): ?array + { + return $this->array('payment_method_options'); + } + + public function isSucceeded(): bool + { + return $this->getStatus() === self::STATUS_SUCCEEDED; + } +} diff --git a/src/Pay/Validator/Stripe/Webhook.php b/src/Pay/Validator/Stripe/Webhook.php index 5ef6e2b..14caa22 100644 --- a/src/Pay/Validator/Stripe/Webhook.php +++ b/src/Pay/Validator/Stripe/Webhook.php @@ -96,7 +96,7 @@ private function getTimestamp($header) $items = \explode(',', $header); foreach ($items as $item) { - $itemParts = \explode('=', $item, 2); + $itemParts = \explode('=', $item, 2) + [1 => '']; if ('t' === $itemParts[0]) { if (! \is_numeric($itemParts[1])) { return -1; @@ -122,7 +122,7 @@ private function getSignatures($header, $scheme) $items = \explode(',', $header); foreach ($items as $item) { - $itemParts = \explode('=', $item, 2); + $itemParts = \explode('=', $item, 2) + [1 => '']; if (\trim($itemParts[0]) === $scheme) { $signatures[] = $itemParts[1]; } diff --git a/src/Pay/Webhook/WebhookEvent.php b/src/Pay/Webhook/WebhookEvent.php new file mode 100644 index 0000000..dfd194a --- /dev/null +++ b/src/Pay/Webhook/WebhookEvent.php @@ -0,0 +1,65 @@ +string('id'); + } + + public function getType(): ?string + { + return $this->string('type'); + } + + /** + * The resource the event is about (`data.object`), e.g. a payment intent or dispute payload + * + * @return array + */ + public function getObject(): array + { + return $this->array('data', 'object') ?? []; + } + + /** + * Kind of resource in getObject(), e.g. `payment_intent`, `mandate` or `dispute` + */ + public function getObjectType(): ?string + { + return $this->string('data', 'object', 'object'); + } +} diff --git a/tests/Pay/Adapter/StripeTest.php b/tests/Pay/Adapter/StripeTest.php index 2d87a61..edceac6 100644 --- a/tests/Pay/Adapter/StripeTest.php +++ b/tests/Pay/Adapter/StripeTest.php @@ -23,6 +23,20 @@ public function testName(): void $this->assertEquals($this->stripe->getName(), 'Stripe'); } + public function testHandleErrorWithStringResponse(): void + { + $handleError = new \ReflectionMethod($this->stripe, 'handleError'); + + try { + $handleError->invoke($this->stripe, 502, 'Bad Gateway'); + $this->fail('Expected exception'); + } catch (Exception $e) { + $this->assertEquals(Exception::GENERAL_UNKNOWN, $e->getType()); + $this->assertEquals('Bad Gateway', $e->getMessage()); + $this->assertEquals(502, $e->getCode()); + } + } + /** * Test create customer * @@ -31,11 +45,11 @@ public function testName(): void public function testCreateCustomer(): array { $customer = $this->stripe->createCustomer('Test customer', 'testcustomer@email.com', ['city' => 'Kathmandu', 'country' => 'NP', 'line1' => 'Gaurighat', 'line2' => 'Pambu Marga', 'postal_code' => '44600', 'state' => 'Bagmati']); - $this->assertNotEmpty($customer['id']); - $this->assertEquals($customer['name'], 'Test customer'); - $this->assertEquals($customer['email'], 'testcustomer@email.com'); + $this->assertNotEmpty($customer->getId()); + $this->assertEquals($customer->getName(), 'Test customer'); + $this->assertEquals($customer->getEmail(), 'testcustomer@email.com'); - return ['customerId' => $customer['id']]; + return ['customerId' => $customer->getId()]; } /** @@ -48,9 +62,9 @@ public function testGetCustomer(array $data): array { $customerId = $data['customerId']; $customer = $this->stripe->getCustomer($customerId); - $this->assertNotEmpty($customer['id']); - $this->assertEquals($customer['name'], 'Test customer'); - $this->assertEquals($customer['email'], 'testcustomer@email.com'); + $this->assertNotEmpty($customer->getId()); + $this->assertEquals($customer->getName(), 'Test customer'); + $this->assertEquals($customer->getEmail(), 'testcustomer@email.com'); return $data; } @@ -65,9 +79,9 @@ public function testUpdateCustomer(array $data): array { $customerId = $data['customerId']; $customer = $this->stripe->updateCustomer($customerId, 'Test Updated', 'testcustomerupdated@email.com'); - $this->assertNotEmpty($customer['id']); - $this->assertEquals($customer['name'], 'Test Updated'); - $this->assertEquals($customer['email'], 'testcustomerupdated@email.com'); + $this->assertNotEmpty($customer->getId()); + $this->assertEquals($customer->getName(), 'Test Updated'); + $this->assertEquals($customer->getEmail(), 'testcustomerupdated@email.com'); return $data; } @@ -79,13 +93,11 @@ public function testUpdateCustomer(array $data): array */ public function testListCustomers(array $data): void { - $response = $this->stripe->listCustomers(); - $this->assertIsArray($response['data']); - $this->assertNotEmpty($response['data']); - $customers = $response['data']; - $this->assertNotEmpty($customers[0]['id']); - $this->assertNotEmpty($customers[0]['name']); - $this->assertNotEmpty($customers[0]['email']); + $customers = $this->stripe->listCustomers(); + $this->assertNotEmpty($customers); + $this->assertNotEmpty($customers[0]->getId()); + $this->assertNotEmpty($customers[0]->getName()); + $this->assertNotEmpty($customers[0]->getEmail()); } /** @@ -103,17 +115,15 @@ public function testCreatePaymentMethod(array $data): array 'exp_year' => 2030, 'cvc' => 123, ]); - $this->assertNotEmpty($pm['id']); - $this->assertNotEmpty($pm['card']); - - $card = $pm['card']; - $this->assertEquals('visa', $card['brand']); - $this->assertEquals('US', $card['country']); - $this->assertEquals(2030, $card['exp_year']); - $this->assertEquals(8, $card['exp_month']); - $this->assertEquals(4242, $card['last4']); + $this->assertNotEmpty($pm->getId()); + $this->assertTrue($pm->isCard()); + $this->assertEquals('visa', $pm->getBrand()); + $this->assertEquals('US', $pm->getCountry()); + $this->assertEquals(2030, $pm->getExpYear()); + $this->assertEquals(8, $pm->getExpMonth()); + $this->assertEquals(4242, $pm->getLast4()); - $data['paymentMethodId'] = $pm['id']; + $data['paymentMethodId'] = $pm->getId(); return $data; } @@ -128,18 +138,16 @@ public function testListPaymentMethods(array $data): array { $customerId = $data['customerId']; $pms = $this->stripe->listPaymentMethods($customerId); - $this->assertIsArray($pms['data']); - - $pm = $pms['data'][0]; - $this->assertNotEmpty($pm['id']); - $this->assertNotEmpty($pm['card']); + $this->assertNotEmpty($pms); - $card = $pm['card']; - $this->assertEquals('visa', $card['brand']); - $this->assertEquals('US', $card['country']); - $this->assertEquals(2030, $card['exp_year']); - $this->assertEquals(8, $card['exp_month']); - $this->assertEquals(4242, $card['last4']); + $pm = $pms[0]; + $this->assertNotEmpty($pm->getId()); + $this->assertTrue($pm->isCard()); + $this->assertEquals('visa', $pm->getBrand()); + $this->assertEquals('US', $pm->getCountry()); + $this->assertEquals(2030, $pm->getExpYear()); + $this->assertEquals(8, $pm->getExpMonth()); + $this->assertEquals(4242, $pm->getLast4()); return $data; } @@ -153,15 +161,13 @@ public function testGetPaymentMethod(array $data): array $customerId = $data['customerId']; $paymentMethodId = $data['paymentMethodId']; $pm = $this->stripe->getPaymentMethod($customerId, $paymentMethodId); - $this->assertNotEmpty($pm['id']); - $this->assertNotEmpty($pm['card']); - - $card = $pm['card']; - $this->assertEquals('visa', $card['brand']); - $this->assertEquals('US', $card['country']); - $this->assertEquals(2030, $card['exp_year']); - $this->assertEquals(8, $card['exp_month']); - $this->assertEquals(4242, $card['last4']); + $this->assertNotEmpty($pm->getId()); + $this->assertTrue($pm->isCard()); + $this->assertEquals('visa', $pm->getBrand()); + $this->assertEquals('US', $pm->getCountry()); + $this->assertEquals(2030, $pm->getExpYear()); + $this->assertEquals(8, $pm->getExpMonth()); + $this->assertEquals(4242, $pm->getLast4()); return $data; } @@ -190,9 +196,8 @@ public function testCreateFuturePayment(array $data): array ], ], ]); - $this->assertNotEmpty($setupIntent); - $this->assertNotEmpty($setupIntent['client_secret']); - $data['setupIntentId'] = $setupIntent['id']; + $this->assertNotEmpty($setupIntent->getClientSecret()); + $data['setupIntentId'] = $setupIntent->getId(); return $data; } @@ -224,12 +229,11 @@ public function testUpdateFuturePayment(array $data): void ], ]); - $this->assertNotEmpty($setupIntent); - $this->assertEquals($setupIntentId, $setupIntent['id']); - $this->assertIsArray($setupIntent['payment_method_options']); - $this->assertArrayHasKey('card', $setupIntent['payment_method_options']); - $this->assertArrayHasKey('mandate_options', $setupIntent['payment_method_options']['card']); - $this->assertEquals($reference, $setupIntent['payment_method_options']['card']['mandate_options']['reference']); + $this->assertEquals($setupIntentId, $setupIntent->getId()); + $this->assertIsArray($setupIntent->getPaymentMethodOptions()); + $this->assertArrayHasKey('card', $setupIntent->getPaymentMethodOptions()); + $this->assertArrayHasKey('mandate_options', $setupIntent->getPaymentMethodOptions()['card']); + $this->assertEquals($reference, $setupIntent->getPaymentMethodOptions()['card']['mandate_options']['reference']); } /** @@ -244,7 +248,7 @@ public function testListFuturePayment(array $data): void $setupIntents = $this->stripe->listFuturePayments($customerId); $this->assertNotEmpty($setupIntents); - $this->assertNotEmpty($setupIntents[0]['id']); + $this->assertNotEmpty($setupIntents[0]->getId()); } /** @@ -260,12 +264,10 @@ public function testUpdatePaymentMethod(array $data): array 'exp_month' => 6, 'exp_year' => 2031, ]); - $this->assertNotEmpty($pm['id']); - $this->assertNotEmpty($pm['card']); - - $card = $pm['card']; - $this->assertEquals(2031, $card['exp_year']); - $this->assertEquals(6, $card['exp_month']); + $this->assertNotEmpty($pm->getId()); + $this->assertTrue($pm->isCard()); + $this->assertEquals(2031, $pm->getExpYear()); + $this->assertEquals(6, $pm->getExpMonth()); return $data; } @@ -282,12 +284,11 @@ public function testPurchase(array $data): array $paymentMethodId = $data['paymentMethodId']; $purchase = $this->stripe->purchase(5000, $customerId, $paymentMethodId); - $this->assertNotEmpty($purchase['id']); - $this->assertEquals(5000, $purchase['amount_received']); - $this->assertEquals('payment_intent', $purchase['object']); - $this->assertEquals('succeeded', $purchase['status']); + $this->assertNotEmpty($purchase->getId()); + $this->assertEquals(5000, $purchase->getAmountReceived()); + $this->assertEquals('succeeded', $purchase->getStatus()); - $data['paymentId'] = $purchase['id']; + $data['paymentId'] = $purchase->getId(); return $data; } @@ -310,8 +311,8 @@ public function testRetryPurchase(array $data): array 'exp_year' => 2030, 'cvc' => 123, ]); - $this->assertNotEmpty($failingPm['id']); - $failingPmId = $failingPm['id']; + $this->assertNotEmpty($failingPm->getId()); + $failingPmId = $failingPm->getId(); // Create a payment intent with the failing payment method $paymentIntentId = null; @@ -333,16 +334,15 @@ public function testRetryPurchase(array $data): array 'exp_year' => 2030, 'cvc' => 123, ]); - $this->assertNotEmpty($succeedingPm['id']); - $succeedingPmId = $succeedingPm['id']; + $this->assertNotEmpty($succeedingPm->getId()); + $succeedingPmId = $succeedingPm->getId(); // Retry the payment intent with the succeeding payment method $result = $this->stripe->retryPurchase((string) $paymentIntentId, $succeedingPmId); - $this->assertNotEmpty($result['id']); - $this->assertEquals($paymentIntentId, $result['id']); - $this->assertEquals('payment_intent', $result['object']); - $this->assertArrayHasKey('status', $result); - $this->assertEquals('succeeded', $result['status']); + $this->assertNotEmpty($result->getId()); + $this->assertEquals($paymentIntentId, $result->getId()); + $this->assertNotNull($result->getStatus()); + $this->assertEquals('succeeded', $result->getStatus()); // Save for further tests if needed $data['paymentId'] = $paymentIntentId; @@ -358,10 +358,9 @@ public function testGetPayment(array $data): array { $paymentId = $data['paymentId']; $payment = $this->stripe->getPayment($paymentId); - $this->assertNotEmpty($payment['id']); - $this->assertEquals(5000, $payment['amount_received']); - $this->assertEquals('payment_intent', $payment['object']); - $this->assertEquals('succeeded', $payment['status']); + $this->assertNotEmpty($payment->getId()); + $this->assertEquals(5000, $payment->getAmountReceived()); + $this->assertEquals('succeeded', $payment->getStatus()); return $data; } @@ -384,8 +383,8 @@ public function testUpdatePayment(array $data): void 'exp_year' => 2030, 'cvc' => 123, ]); - $this->assertNotEmpty($failingPm['id']); - $failingPmId = $failingPm['id']; + $this->assertNotEmpty($failingPm->getId()); + $failingPmId = $failingPm->getId(); // Create a payment intent with the failing payment method $paymentIntentId = null; @@ -407,17 +406,16 @@ public function testUpdatePayment(array $data): void 'exp_year' => 2030, 'cvc' => 123, ]); - $this->assertNotEmpty($succeedingPm['id']); - $succeedingPmId = $succeedingPm['id']; + $this->assertNotEmpty($succeedingPm->getId()); + $succeedingPmId = $succeedingPm->getId(); // Update the payment intent with the new payment method and amount $newAmount = 6000; $updated = $this->stripe->updatePayment((string) $paymentIntentId, $succeedingPmId, $newAmount); - $this->assertNotEmpty($updated['id']); - $this->assertEquals($paymentIntentId, $updated['id']); - $this->assertEquals('payment_intent', $updated['object']); - $this->assertEquals($newAmount, $updated['amount']); - $this->assertEquals($succeedingPmId, $updated['payment_method']); + $this->assertNotEmpty($updated->getId()); + $this->assertEquals($paymentIntentId, $updated->getId()); + $this->assertEquals($newAmount, $updated->getAmount()); + $this->assertEquals($succeedingPmId, $updated->getPaymentMethodId()); } /** @@ -428,10 +426,9 @@ public function testUpdatePayment(array $data): void public function testRefund(array $data): void { $purchase = $this->stripe->refund($data['paymentId'], 3000); - $this->assertNotEmpty($purchase['id']); - $this->assertEquals('refund', $purchase['object']); - $this->assertEquals('succeeded', $purchase['status']); - $this->assertEquals(3000, $purchase['amount']); + $this->assertNotEmpty($purchase->getId()); + $this->assertEquals('succeeded', $purchase->getStatus()); + $this->assertEquals(3000, $purchase->getAmount()); } /** @@ -465,7 +462,7 @@ public function testDeleteCustomer(array $data): void $deleted = $this->stripe->deleteCustomer($customerId); $this->assertTrue($deleted); $res = $this->stripe->getCustomer($customerId); - $this->assertTrue($res['deleted']); + $this->assertTrue($res->isDeleted()); } /** @@ -477,8 +474,8 @@ public function testDeleteCustomer(array $data): void public function testListDisputes(): void { $customer = $this->stripe->createCustomer('Test customer', 'testcustomer@email.com', ['city' => 'Kathmandu', 'country' => 'NP', 'line1' => 'Gaurighat', 'line2' => 'Pambu Marga', 'postal_code' => '44600', 'state' => 'Bagmati']); - $this->assertNotEmpty($customer['id']); - $customerId = $customer['id']; + $this->assertNotEmpty($customer->getId()); + $customerId = $customer->getId(); $pm = $this->stripe->createPaymentMethod($customerId, 'card', [ 'number' => 4000000000000259, @@ -486,35 +483,31 @@ public function testListDisputes(): void 'exp_year' => 2030, 'cvc' => 123, ]); - $this->assertNotEmpty($pm['id']); - $this->assertNotEmpty($pm['card']); - - $card = $pm['card']; - $this->assertEquals('visa', $card['brand']); - $this->assertEquals('US', $card['country']); - $this->assertEquals(2030, $card['exp_year']); - $this->assertEquals(8, $card['exp_month']); - $this->assertEquals('0259', $card['last4']); + $this->assertNotEmpty($pm->getId()); + $this->assertTrue($pm->isCard()); + $this->assertEquals('visa', $pm->getBrand()); + $this->assertEquals('US', $pm->getCountry()); + $this->assertEquals(2030, $pm->getExpYear()); + $this->assertEquals(8, $pm->getExpMonth()); + $this->assertEquals('0259', $pm->getLast4()); - $paymentMethodId = $pm['id']; + $paymentMethodId = $pm->getId(); $purchase = $this->stripe->purchase(5000, $customerId, $paymentMethodId); - $this->assertNotEmpty($purchase['id']); - $this->assertEquals(5000, $purchase['amount_received']); - $this->assertEquals('payment_intent', $purchase['object']); - $this->assertEquals('succeeded', $purchase['status']); + $this->assertNotEmpty($purchase->getId()); + $this->assertEquals(5000, $purchase->getAmountReceived()); + $this->assertEquals('succeeded', $purchase->getStatus()); // list disputes - $paymentIntentId = $purchase['id']; + $paymentIntentId = $purchase->getId(); $disputes = $this->stripe->listDisputes(1); - $this->assertIsArray($disputes); $this->assertEquals(1, count($disputes)); $disputes = $this->stripe->listDisputes(paymentIntentId: $paymentIntentId); $this->assertEquals(1, count($disputes)); - $this->assertEquals($paymentIntentId, $disputes[0]['payment_intent']); + $this->assertEquals($paymentIntentId, $disputes[0]->getPaymentIntentId()); } public function testErrorHandling(): void @@ -527,9 +520,9 @@ public function testErrorHandling(): void } $customer = $this->stripe->createCustomer('Test customer', 'testcustomer@email.com', ['city' => 'Kathmandu', 'country' => 'NP', 'line1' => 'Gaurighat', 'line2' => 'Pambu Marga', 'postal_code' => '44600', 'state' => 'Bagmati']); - $this->assertNotEmpty($customer['id']); + $this->assertNotEmpty($customer->getId()); - $customerId = $customer['id']; + $customerId = $customer->getId(); // incorrect card number try { @@ -599,7 +592,7 @@ public function testAuthorizeCaptureCancelFlow(): array { // Create customer $customer = $this->stripe->createCustomer('Test Auth Customer', 'testauth@email.com'); - $customerId = $customer['id']; + $customerId = $customer->getId(); $this->assertNotEmpty($customerId); // Create payment method @@ -609,7 +602,7 @@ public function testAuthorizeCaptureCancelFlow(): array 'exp_year' => 2030, 'cvc' => 123, ]); - $paymentMethodId = $pm['id']; + $paymentMethodId = $pm->getId(); $this->assertNotEmpty($paymentMethodId); return [ @@ -634,13 +627,12 @@ public function testAuthorize(array $data): array // Authorize payment - hold funds without capturing $authorization = $this->stripe->authorize(10000, $customerId, $paymentMethodId); - $this->assertNotEmpty($authorization['id']); - $this->assertEquals('payment_intent', $authorization['object']); - $this->assertEquals(10000, $authorization['amount']); - $this->assertEquals('requires_capture', $authorization['status']); - $this->assertEquals('manual', $authorization['capture_method']); + $this->assertNotEmpty($authorization->getId()); + $this->assertEquals(10000, $authorization->getAmount()); + $this->assertEquals('requires_capture', $authorization->getStatus()); + $this->assertEquals('manual', $authorization->getRaw()['capture_method']); - $data['authorizationId'] = $authorization['id']; + $data['authorizationId'] = $authorization->getId(); return $data; } @@ -660,10 +652,10 @@ public function testCapture(array $data): array // Capture the full amount $captured = $this->stripe->capture($authorizationId); - $this->assertNotEmpty($captured['id']); - $this->assertEquals($authorizationId, $captured['id']); - $this->assertEquals('succeeded', $captured['status']); - $this->assertEquals(10000, $captured['amount_received']); + $this->assertNotEmpty($captured->getId()); + $this->assertEquals($authorizationId, $captured->getId()); + $this->assertEquals('succeeded', $captured->getStatus()); + $this->assertEquals(10000, $captured->getAmountReceived()); return $data; } @@ -683,15 +675,15 @@ public function testPartialCapture(array $data): array // Authorize payment $authorization = $this->stripe->authorize(15000, $customerId, $paymentMethodId); - $authorizationId = $authorization['id']; + $authorizationId = (string) $authorization->getId(); - $this->assertEquals('requires_capture', $authorization['status']); + $this->assertEquals('requires_capture', $authorization->getStatus()); // Capture partial amount (only 10000 of 15000) $captured = $this->stripe->capture($authorizationId, 10000); - $this->assertEquals('succeeded', $captured['status']); - $this->assertEquals(10000, $captured['amount_received']); + $this->assertEquals('succeeded', $captured->getStatus()); + $this->assertEquals(10000, $captured->getAmountReceived()); return $data; } @@ -711,16 +703,16 @@ public function testCancelAuthorization(array $data): array // Authorize payment $authorization = $this->stripe->authorize(8000, $customerId, $paymentMethodId); - $authorizationId = $authorization['id']; + $authorizationId = (string) $authorization->getId(); - $this->assertEquals('requires_capture', $authorization['status']); + $this->assertEquals('requires_capture', $authorization->getStatus()); // Cancel the authorization - release the hold $cancelled = $this->stripe->cancelAuthorization($authorizationId); - $this->assertNotEmpty($cancelled['id']); - $this->assertEquals($authorizationId, $cancelled['id']); - $this->assertEquals('canceled', $cancelled['status']); + $this->assertNotEmpty($cancelled->getId()); + $this->assertEquals($authorizationId, $cancelled->getId()); + $this->assertEquals('canceled', $cancelled->getStatus()); return $data; } @@ -752,15 +744,15 @@ public function testAuthorizeWithMetadata(array $data): void ] ); - $this->assertNotEmpty($authorization['id']); - $this->assertEquals('requires_capture', $authorization['status']); - $this->assertEquals('example.com', $authorization['metadata']['domain']); - $this->assertEquals('ORD-12345', $authorization['metadata']['order_id']); - $this->assertEquals('domain_registration', $authorization['metadata']['resource_type']); - $this->assertEquals('Domain registration hold for example.com', $authorization['description']); + $this->assertNotEmpty($authorization->getId()); + $this->assertEquals('requires_capture', $authorization->getStatus()); + $this->assertEquals('example.com', $authorization->getMetadata()['domain']); + $this->assertEquals('ORD-12345', $authorization->getMetadata()['order_id']); + $this->assertEquals('domain_registration', $authorization->getMetadata()['resource_type']); + $this->assertEquals('Domain registration hold for example.com', $authorization->getRaw()['description']); // Clean up - $this->stripe->cancelAuthorization($authorization['id']); + $this->stripe->cancelAuthorization($authorization->getId()); $this->stripe->deleteCustomer($customerId); } } diff --git a/tests/Pay/Adapter/StripeWebhookTest.php b/tests/Pay/Adapter/StripeWebhookTest.php new file mode 100644 index 0000000..659e2ab --- /dev/null +++ b/tests/Pay/Adapter/StripeWebhookTest.php @@ -0,0 +1,68 @@ +pay = new Pay(new Stripe('sk_test')); + } + + private function sign(string $payload, int $timestamp): string + { + return 't='.$timestamp.',v1='.hash_hmac('sha256', $timestamp.'.'.$payload, self::SECRET); + } + + public function testConstructWebhookEvent(): void + { + $payload = (string) json_encode([ + 'id' => 'evt_123', + 'type' => WebhookEvent::TYPE_CHARGE_DISPUTE_CREATED, + 'data' => ['object' => ['id' => 'dp_123', 'object' => 'dispute']], + ]); + + $event = $this->pay->constructWebhookEvent($payload, $this->sign($payload, time()), self::SECRET); + + $this->assertEquals('evt_123', $event->getId()); + $this->assertEquals('dp_123', $event->getObject()['id']); + } + + public function testStaleTimestamp(): void + { + $payload = '{"id":"evt_123"}'; + $header = $this->sign($payload, time() - 301); + + $this->assertEquals('evt_123', $this->pay->constructWebhookEvent($payload, $header, self::SECRET, null)->getId()); + + $this->expectException(Exception::class); + $this->pay->constructWebhookEvent($payload, $header, self::SECRET); + } + + public function testBadSignature(): void + { + try { + $this->pay->constructWebhookEvent('{"id":"evt_123"}', $this->sign('{"id":"evt_124"}', time()), self::SECRET); + $this->fail('Expected exception'); + } catch (Exception $e) { + $this->assertEquals(Exception::SIGNATURE_VERIFICATION_FAILED, $e->getType()); + $this->assertEquals(400, $e->getCode()); + } + } + + public function testSignedButInvalidJson(): void + { + $this->expectException(Exception::class); + $this->pay->constructWebhookEvent('not json', $this->sign('not json', time()), self::SECRET); + } +} diff --git a/tests/Pay/AddressTest.php b/tests/Pay/AddressTest.php new file mode 100644 index 0000000..336c8b9 --- /dev/null +++ b/tests/Pay/AddressTest.php @@ -0,0 +1,26 @@ +assertEquals($address->asArray(), Address::fromArray($address->asArray())->asArray()); + } + + public function testFromArrayWithPartialData(): void + { + $address = Address::fromArray(['country' => 'NP', 'city' => null]); + + $this->assertEquals('', $address->getCity()); + $this->assertEquals('NP', $address->getCountry()); + $this->assertNull($address->getLine1()); + $this->assertNull($address->getPostalCode()); + } +} diff --git a/tests/Pay/Charge/ChargeTest.php b/tests/Pay/Charge/ChargeTest.php new file mode 100644 index 0000000..ccf3f6c --- /dev/null +++ b/tests/Pay/Charge/ChargeTest.php @@ -0,0 +1,37 @@ + 'ch_3Q0abc', + 'object' => 'charge', + 'amount' => 2500, + 'amount_refunded' => 2500, + 'currency' => 'usd', + 'refunded' => true, + 'status' => 'succeeded', + ]); + + $this->assertEquals('ch_3Q0abc', $charge->getId()); + $this->assertEquals(2500, $charge->getAmount()); + $this->assertEquals(2500, $charge->getAmountRefunded()); + $this->assertEquals('usd', $charge->getCurrency()); + $this->assertEquals('succeeded', $charge->getStatus()); + $this->assertTrue($charge->isRefunded()); + } + + public function testFromArrayEmpty(): void + { + $charge = Charge::fromArray([]); + + $this->assertNull($charge->getAmountRefunded()); + $this->assertFalse($charge->isRefunded()); + } +} diff --git a/tests/Pay/Customer/CustomerTest.php b/tests/Pay/Customer/CustomerTest.php new file mode 100644 index 0000000..f909aa4 --- /dev/null +++ b/tests/Pay/Customer/CustomerTest.php @@ -0,0 +1,44 @@ + 'cus_Qabc', + 'object' => 'customer', + 'address' => ['city' => 'Kathmandu', 'country' => 'NP', 'line1' => 'Gaurighat', 'line2' => null, 'postal_code' => '44600', 'state' => 'Bagmati'], + 'created' => 1726000000, + 'email' => 'jane@example.com', + 'invoice_settings' => ['default_payment_method' => 'pm_1Q0abc'], + 'metadata' => ['userId' => 'user_1'], + 'name' => 'Jane Doe', + 'phone' => null, + ]); + + $this->assertEquals('cus_Qabc', $customer->getId()); + $this->assertEquals('Jane Doe', $customer->getName()); + $this->assertEquals('jane@example.com', $customer->getEmail()); + $this->assertNull($customer->getPhone()); + $this->assertEquals('pm_1Q0abc', $customer->getDefaultPaymentMethodId()); + $this->assertEquals(['userId' => 'user_1'], $customer->getMetadata()); + $this->assertEquals(1726000000, $customer->getCreatedAt()); + $this->assertEquals('Kathmandu', $customer->getRaw()['address']['city']); + $this->assertFalse($customer->isDeleted()); + } + + public function testFromArrayDeleted(): void + { + $customer = Customer::fromArray(['id' => 'cus_Qabc', 'object' => 'customer', 'deleted' => true]); + + $this->assertEquals('cus_Qabc', $customer->getId()); + $this->assertNull($customer->getName()); + $this->assertNull($customer->getDefaultPaymentMethodId()); + $this->assertTrue($customer->isDeleted()); + } +} diff --git a/tests/Pay/Dispute/DisputeTest.php b/tests/Pay/Dispute/DisputeTest.php new file mode 100644 index 0000000..2b88273 --- /dev/null +++ b/tests/Pay/Dispute/DisputeTest.php @@ -0,0 +1,61 @@ + 'dp_1Q0abc', + 'object' => 'dispute', + 'amount' => 2500, + 'charge' => 'ch_3Q0abc', + 'created' => 1726000000, + 'currency' => 'usd', + 'evidence_details' => ['due_by' => 1727000000, 'has_evidence' => false, 'past_due' => false, 'submission_count' => 0], + 'metadata' => [], + 'payment_intent' => 'pi_3Q0abc', + 'reason' => 'fraudulent', + 'status' => 'needs_response', + ]); + + $this->assertEquals('dp_1Q0abc', $dispute->getId()); + $this->assertEquals(2500, $dispute->getAmount()); + $this->assertEquals('usd', $dispute->getCurrency()); + $this->assertEquals('fraudulent', $dispute->getReason()); + $this->assertEquals('needs_response', $dispute->getStatus()); + $this->assertEquals('ch_3Q0abc', $dispute->getChargeId()); + $this->assertEquals('pi_3Q0abc', $dispute->getPaymentIntentId()); + $this->assertNull($dispute->getPaymentIntentMetadata()); + $this->assertEquals([], $dispute->getMetadata()); + $this->assertEquals(1727000000, $dispute->getEvidenceDueBy()); + $this->assertEquals(1726000000, $dispute->getCreatedAt()); + } + + public function testFromArrayWithExpandedObjects(): void + { + $dispute = Dispute::fromArray([ + 'id' => 'dp_123', + 'charge' => ['id' => 'ch_123', 'object' => 'charge'], + 'payment_intent' => ['id' => 'pi_123', 'object' => 'payment_intent', 'metadata' => ['invoiceId' => 'inv_1', 'teamId' => 'team_1']], + ]); + + $this->assertEquals('ch_123', $dispute->getChargeId()); + $this->assertEquals('pi_123', $dispute->getPaymentIntentId()); + $this->assertEquals(['invoiceId' => 'inv_1', 'teamId' => 'team_1'], $dispute->getPaymentIntentMetadata()); + } + + public function testFromArrayWithoutOptionalFields(): void + { + $dispute = Dispute::fromArray(['id' => 'dp_123', 'payment_intent' => null]); + + $this->assertNull($dispute->getPaymentIntentId()); + $this->assertNull($dispute->getReason()); + $this->assertNull($dispute->getAmount()); + $this->assertNull($dispute->getEvidenceDueBy()); + } +} diff --git a/tests/Pay/Mandate/MandateTest.php b/tests/Pay/Mandate/MandateTest.php new file mode 100644 index 0000000..a0fa57c --- /dev/null +++ b/tests/Pay/Mandate/MandateTest.php @@ -0,0 +1,46 @@ + 'mandate_1Q0abc', + 'object' => 'mandate', + 'customer_acceptance' => ['type' => 'online', 'accepted_at' => 1726000000], + 'payment_method' => 'pm_1Q0abc', + 'status' => 'active', + 'type' => 'multi_use', + ]); + + $this->assertEquals('mandate_1Q0abc', $mandate->getId()); + $this->assertEquals('active', $mandate->getStatus()); + $this->assertEquals('pm_1Q0abc', $mandate->getPaymentMethodId()); + $this->assertTrue($mandate->isActive()); + } + + public function testFromArrayWithExpandedPaymentMethod(): void + { + $mandate = Mandate::fromArray([ + 'id' => 'mandate_123', + 'status' => 'inactive', + 'payment_method' => ['id' => 'pm_123', 'object' => 'payment_method'], + ]); + + $this->assertEquals('pm_123', $mandate->getPaymentMethodId()); + $this->assertFalse($mandate->isActive()); + } + + public function testFromArrayWithoutStatus(): void + { + $mandate = Mandate::fromArray(['id' => 'mandate_123']); + + $this->assertNull($mandate->getStatus()); + $this->assertFalse($mandate->isActive()); + } +} diff --git a/tests/Pay/Payment/PaymentTest.php b/tests/Pay/Payment/PaymentTest.php new file mode 100644 index 0000000..e2a51f4 --- /dev/null +++ b/tests/Pay/Payment/PaymentTest.php @@ -0,0 +1,141 @@ + 'pi_3Q0abc', + 'object' => 'payment_intent', + 'amount' => 2500, + 'amount_received' => 2500, + 'capture_method' => 'automatic', + 'client_secret' => 'pi_3Q0abc_secret_xyz', + 'created' => 1726000000, + 'currency' => 'usd', + 'customer' => 'cus_Qabc', + 'last_payment_error' => null, + 'latest_charge' => 'ch_3Q0abc', + 'metadata' => ['invoiceId' => 'inv_1', 'teamId' => 'team_1'], + 'next_action' => null, + 'payment_method' => 'pm_1Q0abc', + 'status' => 'succeeded', + ]); + + $this->assertEquals('pi_3Q0abc', $payment->getId()); + $this->assertEquals(2500, $payment->getAmount()); + $this->assertEquals(2500, $payment->getAmountReceived()); + $this->assertEquals('usd', $payment->getCurrency()); + $this->assertEquals('succeeded', $payment->getStatus()); + $this->assertEquals('cus_Qabc', $payment->getCustomerId()); + $this->assertEquals('pm_1Q0abc', $payment->getPaymentMethodId()); + $this->assertEquals('ch_3Q0abc', $payment->getLatestChargeId()); + $this->assertEquals('pi_3Q0abc_secret_xyz', $payment->getClientSecret()); + $this->assertEquals(['invoiceId' => 'inv_1', 'teamId' => 'team_1'], $payment->getMetadata()); + $this->assertEquals(1726000000, $payment->getCreatedAt()); + $this->assertNull($payment->getErrorCode()); + $this->assertNull($payment->getNextAction()); + $this->assertEquals([], $payment->getCharges()); + $this->assertEquals('automatic', $payment->getRaw()['capture_method']); + $this->assertTrue($payment->isSucceeded()); + $this->assertFalse($payment->requiresAction()); + } + + public function testFromArrayWithExpandedObjects(): void + { + $payment = Payment::fromArray([ + 'id' => 'pi_123', + 'status' => 'requires_capture', + 'customer' => ['id' => 'cus_123', 'object' => 'customer'], + 'payment_method' => ['id' => 'pm_123', 'object' => 'payment_method'], + 'latest_charge' => ['id' => 'ch_123', 'object' => 'charge'], + ]); + + $this->assertEquals('cus_123', $payment->getCustomerId()); + $this->assertEquals('pm_123', $payment->getPaymentMethodId()); + $this->assertEquals('ch_123', $payment->getLatestChargeId()); + $this->assertTrue($payment->requiresCapture()); + } + + public function testFromArrayWithFailedAttempt(): void + { + $payment = Payment::fromArray([ + 'id' => 'pi_123', + 'object' => 'payment_intent', + 'amount' => 5000, + 'status' => 'requires_payment_method', + 'last_payment_error' => [ + 'code' => 'card_declined', + 'decline_code' => 'insufficient_funds', + 'message' => 'Your card has insufficient funds.', + 'type' => 'card_error', + ], + ]); + + $this->assertEquals('card_declined', $payment->getErrorCode()); + $this->assertEquals('insufficient_funds', $payment->getDeclineCode()); + $this->assertEquals('Your card has insufficient funds.', $payment->getErrorMessage()); + $this->assertTrue($payment->requiresPaymentMethod()); + } + + public function testFromArrayRequiringAction(): void + { + $payment = Payment::fromArray([ + 'id' => 'pi_123', + 'status' => 'requires_action', + 'next_action' => [ + 'type' => 'use_stripe_sdk', + 'use_stripe_sdk' => ['type' => 'three_d_secure_redirect', 'stripe_js' => 'https://hooks.stripe.com/3d_secure_2/hosted'], + ], + ]); + + $this->assertTrue($payment->requiresAction()); + $this->assertEquals('https://hooks.stripe.com/3d_secure_2/hosted', $payment->getNextAction()['use_stripe_sdk']['stripe_js'] ?? null); + } + + public function testFromArrayWithLegacyCharges(): void + { + $payment = Payment::fromArray([ + 'id' => 'pi_123', + 'status' => 'succeeded', + 'charges' => [ + 'object' => 'list', + 'data' => [ + ['id' => 'ch_1', 'object' => 'charge', 'amount' => 2000, 'amount_refunded' => 500, 'refunded' => false, 'status' => 'succeeded'], + ['id' => 'ch_2', 'object' => 'charge', 'amount' => 1000, 'amount_refunded' => 1000, 'refunded' => true, 'status' => 'succeeded'], + ], + 'has_more' => false, + ], + ]); + + $charges = $payment->getCharges(); + $this->assertCount(2, $charges); + $this->assertEquals('ch_1', $charges[0]->getId()); + $this->assertEquals(500, $charges[0]->getAmountRefunded()); + $this->assertFalse($charges[0]->isRefunded()); + $this->assertTrue($charges[1]->isRefunded()); + } + + /** + * Missing fields stay null so callers keep their own fallbacks + */ + public function testFromArrayEmpty(): void + { + $payment = Payment::fromArray([]); + + $this->assertNull($payment->getId()); + $this->assertNull($payment->getStatus()); + $this->assertNull($payment->getAmount()); + $this->assertNull($payment->getCurrency()); + $this->assertNull($payment->getClientSecret()); + $this->assertNull($payment->getErrorMessage()); + $this->assertEquals([], $payment->getMetadata()); + $this->assertEquals([], $payment->getRaw()); + $this->assertFalse($payment->isSucceeded()); + } +} diff --git a/tests/Pay/PaymentMethod/PaymentMethodTest.php b/tests/Pay/PaymentMethod/PaymentMethodTest.php new file mode 100644 index 0000000..c0491f1 --- /dev/null +++ b/tests/Pay/PaymentMethod/PaymentMethodTest.php @@ -0,0 +1,89 @@ + 'pm_1Q0abc', + 'object' => 'payment_method', + 'billing_details' => [ + 'address' => [ + 'city' => 'New York', + 'country' => 'US', + 'line1' => '1 Main St', + 'line2' => null, + 'postal_code' => '10001', + 'state' => 'NY', + ], + 'email' => 'jane@example.com', + 'name' => 'Jane Doe', + 'phone' => null, + ], + 'card' => [ + 'brand' => 'visa', + 'country' => 'US', + 'exp_month' => 8, + 'exp_year' => 2030, + 'funding' => 'credit', + 'last4' => '4242', + ], + 'created' => 1726000000, + 'customer' => 'cus_Qabc', + 'metadata' => [], + 'type' => 'card', + ]); + + $this->assertEquals('pm_1Q0abc', $method->getId()); + $this->assertEquals('card', $method->getType()); + $this->assertEquals('cus_Qabc', $method->getCustomerId()); + $this->assertEquals('visa', $method->getBrand()); + $this->assertEquals('4242', $method->getLast4()); + $this->assertEquals(8, $method->getExpMonth()); + $this->assertEquals(2030, $method->getExpYear()); + $this->assertEquals('credit', $method->getFunding()); + $this->assertEquals('US', $method->getCountry()); + $this->assertEquals('Jane Doe', $method->getBillingName()); + $this->assertEquals('jane@example.com', $method->getBillingEmail()); + $this->assertNull($method->getBillingPhone()); + $this->assertEquals('New York', $method->getBillingCity()); + $this->assertEquals('US', $method->getBillingCountry()); + $this->assertEquals('1 Main St', $method->getBillingLine1()); + $this->assertNull($method->getBillingLine2()); + $this->assertEquals('10001', $method->getBillingPostalCode()); + $this->assertEquals('NY', $method->getBillingState()); + $this->assertEquals(1726000000, $method->getCreatedAt()); + $this->assertTrue($method->isCard()); + } + + public function testFromArrayWithoutCardOrAddress(): void + { + $method = PaymentMethod::fromArray([ + 'id' => 'pm_123', + 'type' => 'sepa_debit', + 'billing_details' => ['address' => ['city' => null, 'country' => null, 'line1' => null, 'line2' => null, 'postal_code' => null, 'state' => null]], + 'customer' => ['id' => 'cus_123', 'object' => 'customer'], + ]); + + $this->assertEquals('cus_123', $method->getCustomerId()); + $this->assertNull($method->getBrand()); + $this->assertNull($method->getExpMonth()); + $this->assertNull($method->getBillingCity()); + $this->assertNull($method->getBillingPostalCode()); + $this->assertFalse($method->isCard()); + $this->assertFalse($method->isExpired()); + } + + public function testIsExpired(): void + { + $method = PaymentMethod::fromArray(['type' => 'card', 'card' => ['exp_month' => 3, 'exp_year' => 2026]]); + + $this->assertFalse($method->isExpired(new \DateTimeImmutable('2026-03-31'))); + $this->assertTrue($method->isExpired(new \DateTimeImmutable('2026-04-01'))); + } +} diff --git a/tests/Pay/Refund/RefundTest.php b/tests/Pay/Refund/RefundTest.php new file mode 100644 index 0000000..2260b79 --- /dev/null +++ b/tests/Pay/Refund/RefundTest.php @@ -0,0 +1,50 @@ + 're_3Q0abc', + 'object' => 'refund', + 'amount' => 3000, + 'charge' => 'ch_3Q0abc', + 'created' => 1726000000, + 'currency' => 'usd', + 'metadata' => [], + 'payment_intent' => 'pi_3Q0abc', + 'reason' => 'requested_by_customer', + 'status' => 'succeeded', + ]); + + $this->assertEquals('re_3Q0abc', $refund->getId()); + $this->assertEquals(3000, $refund->getAmount()); + $this->assertEquals('usd', $refund->getCurrency()); + $this->assertEquals('succeeded', $refund->getStatus()); + $this->assertEquals('pi_3Q0abc', $refund->getPaymentIntentId()); + $this->assertEquals('ch_3Q0abc', $refund->getChargeId()); + $this->assertEquals('requested_by_customer', $refund->getReason()); + $this->assertNull($refund->getFailureReason()); + $this->assertEquals(1726000000, $refund->getCreatedAt()); + $this->assertTrue($refund->isSucceeded()); + } + + public function testFromArrayFailed(): void + { + $refund = Refund::fromArray([ + 'id' => 're_123', + 'status' => 'failed', + 'failure_reason' => 'expired_or_canceled_card', + 'payment_intent' => ['id' => 'pi_123', 'object' => 'payment_intent'], + ]); + + $this->assertEquals('pi_123', $refund->getPaymentIntentId()); + $this->assertEquals('expired_or_canceled_card', $refund->getFailureReason()); + $this->assertFalse($refund->isSucceeded()); + } +} diff --git a/tests/Pay/SetupIntent/SetupIntentTest.php b/tests/Pay/SetupIntent/SetupIntentTest.php new file mode 100644 index 0000000..5c0c1e1 --- /dev/null +++ b/tests/Pay/SetupIntent/SetupIntentTest.php @@ -0,0 +1,50 @@ + 'seti_1Q0abc', + 'object' => 'setup_intent', + 'client_secret' => 'seti_1Q0abc_secret_xyz', + 'customer' => 'cus_Qabc', + 'mandate' => 'mandate_1Q0abc', + 'payment_method' => 'pm_1Q0abc', + 'payment_method_options' => [ + 'card' => ['mandate_options' => ['reference' => 'user_1', 'interval' => 'sporadic'], 'request_three_d_secure' => 'automatic'], + ], + 'status' => 'succeeded', + 'usage' => 'off_session', + ]); + + $this->assertEquals('seti_1Q0abc', $intent->getId()); + $this->assertEquals('succeeded', $intent->getStatus()); + $this->assertEquals('cus_Qabc', $intent->getCustomerId()); + $this->assertEquals('pm_1Q0abc', $intent->getPaymentMethodId()); + $this->assertEquals('seti_1Q0abc_secret_xyz', $intent->getClientSecret()); + $this->assertEquals('mandate_1Q0abc', $intent->getMandateId()); + $this->assertEquals('user_1', $intent->getPaymentMethodOptions()['card']['mandate_options']['reference'] ?? null); + $this->assertTrue($intent->isSucceeded()); + } + + public function testFromArrayPending(): void + { + $intent = SetupIntent::fromArray([ + 'id' => 'seti_123', + 'status' => 'requires_payment_method', + 'payment_method' => null, + 'mandate' => null, + ]); + + $this->assertNull($intent->getPaymentMethodId()); + $this->assertNull($intent->getMandateId()); + $this->assertNull($intent->getClientSecret()); + $this->assertFalse($intent->isSucceeded()); + } +} diff --git a/tests/Pay/Validator/Stripe/WebhookTest.php b/tests/Pay/Validator/Stripe/WebhookTest.php index 9a579b8..6c51cdd 100644 --- a/tests/Pay/Validator/Stripe/WebhookTest.php +++ b/tests/Pay/Validator/Stripe/WebhookTest.php @@ -7,27 +7,51 @@ class WebhookTest extends TestCase { - public function testValid() + private const SECRET = 'whsec_test'; + + private const PAYLOAD = '{"id": "evt_123"}'; + + private function sign(int $timestamp, string $payload = self::PAYLOAD, string $secret = self::SECRET): string { - $header = 't=1723597289,v1=ca18f2c5b48c347b26f2d862f29d93dc1c9c6b319ba2cd934db54333acef1492'; - $secret = getenv('STRIPE_WEBHOOK_SECRET'); + return 't='.$timestamp.',v1='.hash_hmac('sha256', $timestamp.'.'.$payload, $secret); + } - $validator = new Webhook(); + public function testValid(): void + { + $this->assertTrue((new Webhook())->isValid(self::PAYLOAD, $this->sign(time()), self::SECRET, Webhook::DEFAULT_TOLERANCE)); + } - // test valid (Tolerance set to high) - $isValid = $validator->isValid('{"id": "pi_abcdefg"}', $header, $secret, PHP_INT_MAX); - $this->assertTrue($isValid); + public function testAnyMatchingSignatureIsAccepted(): void + { + // Stripe sends one v1 entry per active secret while a secret is being rolled + $header = $this->sign(time()).',v1='.str_repeat('0', 64).',v0=legacy'; - // Test time tolerance low - $isValid = $validator->isValid('{"id": "pi_abcdefg"}', $header, $secret, 10); - $this->assertFalse($isValid); + $this->assertTrue((new Webhook())->isValid(self::PAYLOAD, $header, self::SECRET, Webhook::DEFAULT_TOLERANCE)); + } + + public function testStaleTimestamp(): void + { + $header = $this->sign(time() - Webhook::DEFAULT_TOLERANCE - 1); - // payload doesn't match - $isValid = $validator->isValid('{"id": "pi_abcdef"}', $header, $secret, PHP_INT_MAX); - $this->assertFalse($isValid); + $this->assertFalse((new Webhook())->isValid(self::PAYLOAD, $header, self::SECRET, Webhook::DEFAULT_TOLERANCE)); + $this->assertTrue((new Webhook())->isValid(self::PAYLOAD, $header, self::SECRET, null)); + } + + public function testBadSignature(): void + { + $validator = new Webhook(); + + $this->assertFalse($validator->isValid('{"id": "evt_124"}', $this->sign(time()), self::SECRET, Webhook::DEFAULT_TOLERANCE)); + $this->assertFalse($validator->isValid(self::PAYLOAD, $this->sign(time()), self::SECRET.'x', Webhook::DEFAULT_TOLERANCE)); + } + + public function testMalformedHeader(): void + { + $validator = new Webhook(); - // Secret doesn't match - $isValid = $validator->isValid('{"id": "pi_abcdefg"}', $header, $secret.'ef', PHP_INT_MAX); - $this->assertFalse($isValid); + $this->assertFalse($validator->isValid(self::PAYLOAD, '', self::SECRET)); + $this->assertFalse($validator->isValid(self::PAYLOAD, 't,v1', self::SECRET)); + $this->assertFalse($validator->isValid(self::PAYLOAD, 't=abc,v1=def', self::SECRET)); + $this->assertFalse($validator->isValid(self::PAYLOAD, 't='.time(), self::SECRET)); } } diff --git a/tests/Pay/Webhook/WebhookEventTest.php b/tests/Pay/Webhook/WebhookEventTest.php new file mode 100644 index 0000000..d4f69ae --- /dev/null +++ b/tests/Pay/Webhook/WebhookEventTest.php @@ -0,0 +1,51 @@ + 'evt_123', + 'object' => 'event', + 'type' => 'charge.dispute.created', + 'data' => ['object' => ['id' => 'dp_123', 'object' => 'dispute']], + ]); + + $this->assertEquals('evt_123', $event->getId()); + $this->assertEquals(WebhookEvent::TYPE_CHARGE_DISPUTE_CREATED, $event->getType()); + $this->assertEquals('dispute', $event->getObjectType()); + $this->assertEquals('dp_123', $event->getObject()['id']); + } + + public function testFromArrayEventTypes(): void + { + $succeeded = WebhookEvent::fromArray([ + 'id' => 'evt_1', + 'type' => 'payment_intent.succeeded', + 'data' => ['object' => ['id' => 'pi_123', 'object' => 'payment_intent']], + ]); + $mandate = WebhookEvent::fromArray([ + 'id' => 'evt_2', + 'type' => 'mandate.updated', + 'data' => ['object' => ['id' => 'mandate_123', 'object' => 'mandate']], + ]); + + $this->assertEquals(WebhookEvent::TYPE_PAYMENT_INTENT_SUCCEEDED, $succeeded->getType()); + $this->assertEquals('payment_intent', $succeeded->getObjectType()); + $this->assertEquals(WebhookEvent::TYPE_MANDATE_UPDATED, $mandate->getType()); + $this->assertEquals('mandate', $mandate->getObjectType()); + } + + public function testFromArrayWithoutObject(): void + { + $event = WebhookEvent::fromArray(['id' => 'evt_123', 'type' => 'ping']); + + $this->assertEquals([], $event->getObject()); + $this->assertNull($event->getObjectType()); + } +}