diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62886e942..ced3b0e8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,9 +112,7 @@ jobs: - "0" - "1" php-version: - - "5.6" - - "7.0" - - "7.1" + # https://docs.stripe.com/sdks/versioning?lang=php - "7.2" - "7.3" - "7.4" @@ -123,6 +121,7 @@ jobs: - "8.2" - "8.3" - "8.4" + - "8.5" name: Tests (php@${{ matrix.php-version }}, AUTOLOAD=${{ matrix.autoload }}) steps: @@ -167,8 +166,7 @@ jobs: name: Publish if: >- (github.event_name == 'workflow_dispatch' || github.event_name == 'push') && - startsWith(github.ref, 'refs/tags/v') && - endsWith(github.actor, '-stripe') + startsWith(github.ref, 'refs/tags/v') runs-on: "ubuntu-24.04" permissions: contents: read diff --git a/README.md b/README.md index 306a6f6e3..0c37a4eed 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ API. ## Requirements -PHP 5.6.0 and later. +PHP 7.2.0 and later. -Note that per our [language version support policy](https://docs.stripe.com/sdks/versioning?lang=php#stripe-sdk-language-version-support-policy), support for PHP 5.6, 7.0, and 7.1 will be removed in the March 2026 major version. +Note that per our [language version support policy](https://docs.stripe.com/sdks/versioning?lang=php#stripe-sdk-language-version-support-policy), support for PHP 7.2 and 7.3 will be removed soon, so upgrade your runtime if you're able to. Additional PHP versions will be dropped in future major versions, so upgrade to supported versions if possible. diff --git a/composer.json b/composer.json index 5bf2d54b2..e2bd71205 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ } ], "require": { - "php": ">=5.6.0", + "php": ">=7.2.0", "ext-curl": "*", "ext-json": "*", "ext-mbstring": "*" @@ -28,7 +28,10 @@ "autoload": { "psr-4": { "Stripe\\": "lib/" - } + }, + "files": [ + "lib/version_check.php" + ] }, "autoload-dev": { "psr-4": { @@ -45,9 +48,9 @@ }, "config": { "audit": { - "ignore": { - "PKSA-z3gr-8qht-p93v": "PHPUnit is only a dev dependency. Temporarily ignore PHPUnit security advisory to ensure continued support for PHP 5.6 in CI." - } + "ignore": { + "PKSA-z3gr-8qht-p93v": "PHPUnit is only a dev dependency. Temporarily ignore PHPUnit security advisory to ensure continued support for PHP 5.6 in CI." + } } } } diff --git a/init.php b/init.php index d3cf83b3a..6d6b20ff8 100644 --- a/init.php +++ b/init.php @@ -1,5 +1,7 @@ getClient()->request($method, $path, self::formatParams($params), $opts); + $params = self::formatParams($params); + if (null !== $schemas && isset($schemas['request_schema'])) { + $params = \Stripe\Util\Int64::coerceRequestParams($params, $schemas['request_schema']); + } + + return $this->getClient()->request($method, $path, $params, $opts); } protected function requestStream($method, $path, $readBodyChunkCallable, $params, $opts) @@ -80,14 +85,24 @@ protected function requestStream($method, $path, $readBodyChunkCallable, $params return $this->getStreamingClient()->requestStream($method, $path, $readBodyChunkCallable, self::formatParams($params), $opts); } - protected function requestCollection($method, $path, $params, $opts) + protected function requestCollection($method, $path, $params, $opts, $schemas = null) { - return $this->getClient()->requestCollection($method, $path, self::formatParams($params), $opts); + $params = self::formatParams($params); + if (null !== $schemas && isset($schemas['request_schema'])) { + $params = \Stripe\Util\Int64::coerceRequestParams($params, $schemas['request_schema']); + } + + return $this->getClient()->requestCollection($method, $path, $params, $opts); } - protected function requestSearchResult($method, $path, $params, $opts) + protected function requestSearchResult($method, $path, $params, $opts, $schemas = null) { - return $this->getClient()->requestSearchResult($method, $path, self::formatParams($params), $opts); + $params = self::formatParams($params); + if (null !== $schemas && isset($schemas['request_schema'])) { + $params = \Stripe\Util\Int64::coerceRequestParams($params, $schemas['request_schema']); + } + + return $this->getClient()->requestSearchResult($method, $path, $params, $opts); } protected function buildPath($basePath, ...$ids) diff --git a/lib/StripeObject.php b/lib/StripeObject.php index 58eb10986..6c3b30694 100644 --- a/lib/StripeObject.php +++ b/lib/StripeObject.php @@ -296,6 +296,16 @@ public function refreshFrom($values, $opts, $partial = false, $apiMode = 'v1') $values = $values->toArray(); } + // Apply int64_string response coercion on raw values before hydration. + // V2 resource classes declare fieldEncodings() with metadata about which + // fields are int64_string (wire format: JSON string, SDK type: PHP int). + if (\method_exists(static::class, 'fieldEncodings')) { + $encodings = static::fieldEncodings(); + if (!empty($encodings)) { + $values = Util\Int64::coerceResponseValues($values, $encodings); + } + } + // Wipe old state before setting new. This is useful for e.g. updating a // customer, where there is no persistent card parameter. Mark those values // which don't persist as transient @@ -331,7 +341,8 @@ public function updateAttributes($values, $opts = null, $dirty = true, $apiMode // This is necessary in case metadata is empty, as PHP arrays do // not differentiate between lists and hashes, and we consider // empty arrays to be lists. - if (('metadata' === $k) && \is_array($v)) { + // The same applies to the previous_attributes attribute. + if (('metadata' === $k || 'previous_attributes' === $k) && \is_array($v)) { $this->_values[$k] = StripeObject::constructFrom($v, $opts, $apiMode); } else { $this->_values[$k] = Util\Util::convertToStripeObject($v, $opts, $apiMode); diff --git a/lib/Util/Int64.php b/lib/Util/Int64.php new file mode 100644 index 000000000..ff2bccbca --- /dev/null +++ b/lib/Util/Int64.php @@ -0,0 +1,128 @@ + 'object', 'fields' => ['amount' => ['kind' => 'int64_string']]] + * + * @return mixed + */ + public static function coerceRequestParams($params, $schema) + { + if (null === $params) { + return null; + } + + if (!isset($schema['kind'])) { + return $params; + } + + if ('int64_string' === $schema['kind']) { + if (\is_int($params)) { + return (string) $params; + } + + return $params; + } + + if ('array' === $schema['kind'] && isset($schema['items'])) { + if (\is_array($params)) { + $result = []; + foreach ($params as $key => $value) { + $result[$key] = self::coerceRequestParams($value, $schema['items']); + } + + return $result; + } + + return $params; + } + + if ('object' === $schema['kind'] && isset($schema['fields'])) { + if (\is_array($params)) { + $result = $params; + foreach ($schema['fields'] as $field => $fieldSchema) { + if (\array_key_exists($field, $result)) { + $result[$field] = self::coerceRequestParams($result[$field], $fieldSchema); + } + } + + return $result; + } + + return $params; + } + + return $params; + } + + /** + * Coerce inbound response values: convert JSON strings to PHP ints where + * the field encodings indicate an int64_string field. + * + * @param mixed $values + * @param array $encodings e.g. ['amount' => ['kind' => 'int64_string'], 'nested' => ['kind' => 'object', 'fields' => [...]]] + * + * @return mixed + */ + public static function coerceResponseValues($values, $encodings) + { + if (!\is_array($values)) { + return $values; + } + + foreach ($encodings as $field => $encoding) { + if (!\array_key_exists($field, $values)) { + continue; + } + + $value = $values[$field]; + + if (!isset($encoding['kind'])) { + continue; + } + + if ('int64_string' === $encoding['kind']) { + if (\is_string($value) && \is_numeric($value)) { + $values[$field] = (int) $value; + } + } elseif ('array' === $encoding['kind'] && isset($encoding['items'])) { + if (\is_array($value)) { + foreach ($value as $i => $item) { + if (!isset($encoding['items']['kind'])) { + continue; + } + + if ('int64_string' === $encoding['items']['kind']) { + if (\is_string($item) && \is_numeric($item)) { + $values[$field][$i] = (int) $item; + } + } elseif ('object' === $encoding['items']['kind'] && isset($encoding['items']['fields'])) { + if (\is_array($item)) { + $values[$field][$i] = self::coerceResponseValues($item, $encoding['items']['fields']); + } + } + } + } + } elseif ('object' === $encoding['kind'] && isset($encoding['fields'])) { + if (\is_array($value)) { + $values[$field] = self::coerceResponseValues($value, $encoding['fields']); + } + } + } + + return $values; + } +} diff --git a/lib/version_check.php b/lib/version_check.php new file mode 100644 index 000000000..a253e2d9c --- /dev/null +++ b/lib/version_check.php @@ -0,0 +1,9 @@ +createMock(\Stripe\StripeClientInterface::class); + $mockClient->expects(self::once()) + ->method('request') + ->with( + self::equalTo('post'), + self::equalTo('/v2/test'), + self::callback(static function ($params) use (&$capturedParams) { + $capturedParams = $params; + + return true; + }), + self::anything() + ) + ->willReturn(\Stripe\StripeObject::constructFrom([])) + ; + + $service = new ConcreteTestService($mockClient); + $schemas = [ + 'request_schema' => [ + 'kind' => 'object', + 'fields' => [ + 'amount' => ['kind' => 'int64_string'], + ], + ], + ]; + $service->publicRequest('post', '/v2/test', ['amount' => 100, 'currency' => 'usd'], [], $schemas); + + self::assertSame('100', $capturedParams['amount']); + self::assertSame('usd', $capturedParams['currency']); + } + + public function testRequestDoesNotCoerceWithoutSchema() + { + $capturedParams = null; + $mockClient = $this->createMock(\Stripe\StripeClientInterface::class); + $mockClient->expects(self::once()) + ->method('request') + ->with( + self::anything(), + self::anything(), + self::callback(static function ($params) use (&$capturedParams) { + $capturedParams = $params; + + return true; + }), + self::anything() + ) + ->willReturn(\Stripe\StripeObject::constructFrom([])) + ; + + $service = new ConcreteTestService($mockClient); + $service->publicRequest('post', '/v2/test', ['amount' => 100], []); + + self::assertSame(100, $capturedParams['amount']); + } + + public function testRequestCollectionCoercesInt64Params() + { + $capturedParams = null; + $mockClient = $this->createMock(\Stripe\StripeClientInterface::class); + $mockClient->expects(self::once()) + ->method('requestCollection') + ->with( + self::anything(), + self::anything(), + self::callback(static function ($params) use (&$capturedParams) { + $capturedParams = $params; + + return true; + }), + self::anything() + ) + ->willReturn(\Stripe\Collection::constructFrom(['data' => []])) + ; + + $service = new ConcreteTestService($mockClient); + $schemas = [ + 'request_schema' => [ + 'kind' => 'object', + 'fields' => [ + 'limit' => ['kind' => 'int64_string'], + ], + ], + ]; + $service->publicRequestCollection('get', '/v2/test', ['limit' => 50], [], $schemas); + + self::assertSame('50', $capturedParams['limit']); + } + + public function testRequestSearchResultCoercesInt64Params() + { + $capturedParams = null; + $mockClient = $this->createMock(\Stripe\StripeClientInterface::class); + $mockClient->expects(self::once()) + ->method('requestSearchResult') + ->with( + self::anything(), + self::anything(), + self::callback(static function ($params) use (&$capturedParams) { + $capturedParams = $params; + + return true; + }), + self::anything() + ) + ->willReturn(\Stripe\SearchResult::constructFrom(['data' => []])) + ; + + $service = new ConcreteTestService($mockClient); + $schemas = [ + 'request_schema' => [ + 'kind' => 'object', + 'fields' => [ + 'amount_gte' => ['kind' => 'int64_string'], + ], + ], + ]; + $service->publicRequestSearchResult('get', '/v2/test', ['amount_gte' => 1000], [], $schemas); + + self::assertSame('1000', $capturedParams['amount_gte']); + } +} + +/** + * @internal + * Concrete subclass that exposes protected methods for testing + */ +final class ConcreteTestService extends AbstractService +{ + public function publicRequest($method, $path, $params, $opts, $schemas = null) + { + return $this->request($method, $path, $params, $opts, $schemas); + } + + public function publicRequestCollection($method, $path, $params, $opts, $schemas = null) + { + return $this->requestCollection($method, $path, $params, $opts, $schemas); + } + + public function publicRequestSearchResult($method, $path, $params, $opts, $schemas = null) + { + return $this->requestSearchResult($method, $path, $params, $opts, $schemas); + } } diff --git a/tests/Stripe/StripeObjectTest.php b/tests/Stripe/StripeObjectTest.php index aacf9c62e..4ce42c7aa 100644 --- a/tests/Stripe/StripeObjectTest.php +++ b/tests/Stripe/StripeObjectTest.php @@ -423,7 +423,7 @@ public function testSerializeParametersRaisesExceotionOnOtherEmbeddedApiResource } catch (\InvalidArgumentException $e) { self::assertSame( 'Cannot save property `customer` containing an API resource of type Stripe\Customer. ' - . "It doesn't appear to be persisted and is not marked as `saveWithParent`.", + . "It doesn't appear to be persisted and is not marked as `saveWithParent`.", $e->getMessage() ); } catch (\Exception $e) { @@ -597,4 +597,83 @@ public function testDeserializeMetadataWithKeyNamedMetadata() $inner = $obj->metadata; self::assertSame('value', $inner->metadata); } + + public function testDeserializeEmptyPreviousAttributes() + { + /** @var mixed $obj */ + $obj = StripeObject::constructFrom([ + 'data' => [ + 'previous_attributes' => [], + ], + ]); + + self::assertInstanceOf(StripeObject::class, $obj->data->previous_attributes); + } + + public function testRefreshFromCoercesInt64ResponseValues() + { + $obj = StripeObjectWithInt64Fields::constructFrom([ + 'id' => 'obj_123', + 'amount' => '12345', + 'currency' => 'usd', + ]); + + self::assertSame(12345, $obj['amount']); + self::assertSame('usd', $obj['currency']); + } + + public function testRefreshFromCoercesNestedInt64ResponseValues() + { + $obj = StripeObjectWithNestedInt64Fields::constructFrom([ + 'id' => 'obj_123', + 'details' => ['amount' => '999', 'label' => 'test'], + ]); + + self::assertSame(999, $obj['details']['amount']); + self::assertSame('test', $obj['details']['label']); + } + + public function testRefreshFromSkipsCoercionWhenNoFieldEncodings() + { + $obj = StripeObject::constructFrom([ + 'id' => 'obj_123', + 'amount' => '12345', + ]); + + // Without fieldEncodings(), string values stay as strings + self::assertSame('12345', $obj['amount']); + } +} + +/** + * @internal + * Test subclass that declares int64_string field encodings + */ +final class StripeObjectWithInt64Fields extends StripeObject +{ + public static function fieldEncodings() + { + return [ + 'amount' => ['kind' => 'int64_string'], + ]; + } +} + +/** + * @internal + * Test subclass with nested int64_string field encodings + */ +final class StripeObjectWithNestedInt64Fields extends StripeObject +{ + public static function fieldEncodings() + { + return [ + 'details' => [ + 'kind' => 'object', + 'fields' => [ + 'amount' => ['kind' => 'int64_string'], + ], + ], + ]; + } } diff --git a/tests/Stripe/Util/Int64Test.php b/tests/Stripe/Util/Int64Test.php new file mode 100644 index 000000000..134177349 --- /dev/null +++ b/tests/Stripe/Util/Int64Test.php @@ -0,0 +1,320 @@ + 'object', 'fields' => ['amount' => ['kind' => 'int64_string']]]; + self::assertNull(Int64::coerceRequestParams(null, $schema)); + } + + public function testCoerceRequestParamsReturnsParamsWhenSchemaHasNoKind() + { + $params = ['amount' => 42]; + self::assertSame($params, Int64::coerceRequestParams($params, [])); + } + + public function testCoerceRequestParamsConvertsIntToStringForInt64Field() + { + $schema = ['kind' => 'int64_string']; + self::assertSame('42', Int64::coerceRequestParams(42, $schema)); + } + + public function testCoerceRequestParamsPassesThroughStringForInt64Field() + { + $schema = ['kind' => 'int64_string']; + self::assertSame('42', Int64::coerceRequestParams('42', $schema)); + } + + public function testCoerceRequestParamsConvertsObjectFieldsSelectively() + { + $schema = [ + 'kind' => 'object', + 'fields' => [ + 'amount' => ['kind' => 'int64_string'], + ], + ]; + $params = ['amount' => 100, 'currency' => 'usd']; + $result = Int64::coerceRequestParams($params, $schema); + + self::assertSame('100', $result['amount']); + self::assertSame('usd', $result['currency']); + } + + public function testCoerceRequestParamsLeavesUnmatchedFieldsAlone() + { + $schema = [ + 'kind' => 'object', + 'fields' => [ + 'amount' => ['kind' => 'int64_string'], + ], + ]; + $params = ['description' => 'test']; + $result = Int64::coerceRequestParams($params, $schema); + + self::assertSame('test', $result['description']); + } + + public function testCoerceRequestParamsHandlesArrayOfInt64() + { + $schema = [ + 'kind' => 'array', + 'items' => ['kind' => 'int64_string'], + ]; + $params = [1, 2, 3]; + $result = Int64::coerceRequestParams($params, $schema); + + self::assertSame(['1', '2', '3'], $result); + } + + public function testCoerceRequestParamsHandlesArrayOfObjects() + { + $schema = [ + 'kind' => 'array', + 'items' => [ + 'kind' => 'object', + 'fields' => [ + 'amount' => ['kind' => 'int64_string'], + ], + ], + ]; + $params = [ + ['amount' => 100, 'currency' => 'usd'], + ['amount' => 200, 'currency' => 'eur'], + ]; + $result = Int64::coerceRequestParams($params, $schema); + + self::assertSame('100', $result[0]['amount']); + self::assertSame('usd', $result[0]['currency']); + self::assertSame('200', $result[1]['amount']); + self::assertSame('eur', $result[1]['currency']); + } + + public function testCoerceRequestParamsHandlesNestedObjects() + { + $schema = [ + 'kind' => 'object', + 'fields' => [ + 'outer' => [ + 'kind' => 'object', + 'fields' => [ + 'amount' => ['kind' => 'int64_string'], + ], + ], + ], + ]; + $params = ['outer' => ['amount' => 500, 'label' => 'test']]; + $result = Int64::coerceRequestParams($params, $schema); + + self::assertSame('500', $result['outer']['amount']); + self::assertSame('test', $result['outer']['label']); + } + + public function testCoerceRequestParamsPassesThroughNonArrayForObjectSchema() + { + $schema = [ + 'kind' => 'object', + 'fields' => [ + 'amount' => ['kind' => 'int64_string'], + ], + ]; + self::assertSame('not-an-array', Int64::coerceRequestParams('not-an-array', $schema)); + } + + public function testCoerceRequestParamsPassesThroughNonArrayForArraySchema() + { + $schema = [ + 'kind' => 'array', + 'items' => ['kind' => 'int64_string'], + ]; + self::assertSame('not-an-array', Int64::coerceRequestParams('not-an-array', $schema)); + } + + public function testCoerceRequestParamsPassesThroughForUnknownKind() + { + $schema = ['kind' => 'unknown_type']; + $params = ['foo' => 'bar']; + self::assertSame($params, Int64::coerceRequestParams($params, $schema)); + } + + // ——— coerceResponseValues ——— + + public function testCoerceResponseValuesReturnsNonArrayAsIs() + { + $encodings = ['amount' => ['kind' => 'int64_string']]; + self::assertSame('not-an-array', Int64::coerceResponseValues('not-an-array', $encodings)); + } + + public function testCoerceResponseValuesConvertsNumericStringToInt() + { + $encodings = ['amount' => ['kind' => 'int64_string']]; + $values = ['amount' => '12345']; + $result = Int64::coerceResponseValues($values, $encodings); + + self::assertSame(12345, $result['amount']); + } + + public function testCoerceResponseValuesLeavesNonNumericStringAlone() + { + $encodings = ['amount' => ['kind' => 'int64_string']]; + $values = ['amount' => 'not-a-number']; + $result = Int64::coerceResponseValues($values, $encodings); + + self::assertSame('not-a-number', $result['amount']); + } + + public function testCoerceResponseValuesLeavesNonIntFieldsAlone() + { + $encodings = ['amount' => ['kind' => 'int64_string']]; + $values = ['amount' => '100', 'currency' => 'usd']; + $result = Int64::coerceResponseValues($values, $encodings); + + self::assertSame(100, $result['amount']); + self::assertSame('usd', $result['currency']); + } + + public function testCoerceResponseValuesSkipsMissingFields() + { + $encodings = ['amount' => ['kind' => 'int64_string']]; + $values = ['currency' => 'usd']; + $result = Int64::coerceResponseValues($values, $encodings); + + self::assertSame(['currency' => 'usd'], $result); + } + + public function testCoerceResponseValuesSkipsEncodingsWithNoKind() + { + $encodings = ['amount' => []]; + $values = ['amount' => '100']; + $result = Int64::coerceResponseValues($values, $encodings); + + self::assertSame('100', $result['amount']); + } + + public function testCoerceResponseValuesHandlesArrayOfInt64() + { + $encodings = [ + 'amounts' => [ + 'kind' => 'array', + 'items' => ['kind' => 'int64_string'], + ], + ]; + $values = ['amounts' => ['100', '200', '300']]; + $result = Int64::coerceResponseValues($values, $encodings); + + self::assertSame([100, 200, 300], $result['amounts']); + } + + public function testCoerceResponseValuesHandlesArrayOfObjects() + { + $encodings = [ + 'line_items' => [ + 'kind' => 'array', + 'items' => [ + 'kind' => 'object', + 'fields' => [ + 'amount' => ['kind' => 'int64_string'], + ], + ], + ], + ]; + $values = [ + 'line_items' => [ + ['amount' => '100', 'description' => 'Widget'], + ['amount' => '200', 'description' => 'Gadget'], + ], + ]; + $result = Int64::coerceResponseValues($values, $encodings); + + self::assertSame(100, $result['line_items'][0]['amount']); + self::assertSame('Widget', $result['line_items'][0]['description']); + self::assertSame(200, $result['line_items'][1]['amount']); + self::assertSame('Gadget', $result['line_items'][1]['description']); + } + + public function testCoerceResponseValuesHandlesNestedObject() + { + $encodings = [ + 'details' => [ + 'kind' => 'object', + 'fields' => [ + 'amount' => ['kind' => 'int64_string'], + ], + ], + ]; + $values = ['details' => ['amount' => '999', 'label' => 'test']]; + $result = Int64::coerceResponseValues($values, $encodings); + + self::assertSame(999, $result['details']['amount']); + self::assertSame('test', $result['details']['label']); + } + + public function testCoerceResponseValuesHandlesNonArrayValueForArrayEncoding() + { + $encodings = [ + 'amounts' => [ + 'kind' => 'array', + 'items' => ['kind' => 'int64_string'], + ], + ]; + $values = ['amounts' => 'not-an-array']; + $result = Int64::coerceResponseValues($values, $encodings); + + self::assertSame('not-an-array', $result['amounts']); + } + + public function testCoerceResponseValuesHandlesNonArrayValueForObjectEncoding() + { + $encodings = [ + 'details' => [ + 'kind' => 'object', + 'fields' => [ + 'amount' => ['kind' => 'int64_string'], + ], + ], + ]; + $values = ['details' => 'not-an-array']; + $result = Int64::coerceResponseValues($values, $encodings); + + self::assertSame('not-an-array', $result['details']); + } + + public function testCoerceResponseValuesHandlesNegativeNumbers() + { + $encodings = ['amount' => ['kind' => 'int64_string']]; + $values = ['amount' => '-500']; + $result = Int64::coerceResponseValues($values, $encodings); + + self::assertSame(-500, $result['amount']); + } + + public function testCoerceResponseValuesHandlesZero() + { + $encodings = ['amount' => ['kind' => 'int64_string']]; + $values = ['amount' => '0']; + $result = Int64::coerceResponseValues($values, $encodings); + + self::assertSame(0, $result['amount']); + } + + public function testCoerceRequestParamsHandlesZero() + { + $schema = ['kind' => 'int64_string']; + self::assertSame('0', Int64::coerceRequestParams(0, $schema)); + } + + public function testCoerceRequestParamsHandlesNegativeInt() + { + $schema = ['kind' => 'int64_string']; + self::assertSame('-500', Int64::coerceRequestParams(-500, $schema)); + } +}