From d9d33bbd0f91ba35f0d052d50ba4df2bd2607b9c Mon Sep 17 00:00:00 2001 From: Bakhtarian Date: Tue, 2 Jun 2026 11:43:07 +0200 Subject: [PATCH 1/2] fix(payment): align Payment DTO with current Whop schema The Payment DTO required a flat integer `amount` field that no longer exists in Whop's payments API, so payments->get() and ->refund() threw MissingArgumentsException on every real response. Replace `amount` with the actual nullable monetary fields (total, subtotal, usdTotal, refundedAmount as ?float), read the application fee from the `application_fee` object (.amount), and read `substatus` (current name) with a `sub_status` fallback. --- CHANGELOG.md | 20 +++ src/Dto/Payment/Payment.php | 63 +++++++--- tests/Dto/PaymentTest.php | 237 ++++++++++++++++++++++++++++++------ 3 files changed, 267 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 836ea95..fa27382 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- `Payment` DTO now matches the current Whop payments schema. It previously + **required** a flat integer `amount` field that no longer exists in the API, + causing `payments->get()` and `->refund()` to throw `MissingArgumentsException` + on every real response. + +### Changed (breaking) + +- `Payment` no longer exposes `amount`. It now exposes the actual nullable + monetary fields `total`, `subtotal`, `usdTotal`, and `refundedAmount` (all + `?float`). +- `Payment::$applicationFee` is now read from the `application_fee` **object** + (`application_fee.amount`), falling back to a flat numeric `application_fee` + and then `application_fee_amount`. Its type changed from `?int` to `?float`. +- `Payment::$subStatus` is now read from `substatus` (the current field name), + falling back to the legacy `sub_status`. + ## [0.0.1] - 2026-05-15 Initial release — a framework-agnostic PHP SDK for the Whop API, extracted from the diff --git a/src/Dto/Payment/Payment.php b/src/Dto/Payment/Payment.php index 2af0ded..cda14f5 100644 --- a/src/Dto/Payment/Payment.php +++ b/src/Dto/Payment/Payment.php @@ -15,16 +15,28 @@ private function __construct( public string $id, public string $status, public ?string $subStatus, - public int $amount, + public ?float $total, + public ?float $subtotal, + public ?float $usdTotal, + public ?float $refundedAmount, public string $currency, - public ?int $applicationFee, + public ?float $applicationFee, public array $metadata, public ?string $createdAt, ) { } /** + * Hydrate a Payment from a Whop API response. + * + * The monetary value lives in `total`/`subtotal`/`usd_total` (numbers, + * nullable) — there is no flat `amount` field. `status` carries the + * machine status and `substatus` the friendly one. `application_fee` is an + * object whose `amount` holds the fee. + * * @param array $data + * + * @throws MissingArgumentsException when id, status, or currency are absent */ public static function create(array $data): self { @@ -40,35 +52,58 @@ public static function create(array $data): self throw MissingArgumentsException::forField(context: 'Payment response', field: 'status'); } - $amount = $data['amount'] ?? null; - - if (!\is_int($amount)) { - throw MissingArgumentsException::forField(context: 'Payment response', field: 'amount'); - } - $currencyRaw = $data['currency'] ?? null; if (!\is_string($currencyRaw) || '' === $currencyRaw) { throw MissingArgumentsException::forField(context: 'Payment response', field: 'currency'); } - $subStatus = $data['sub_status'] ?? null; - $applicationFee = $data['application_fee'] ?? $data['application_fee_amount'] ?? null; - $metadataRaw = $data['metadata'] ?? []; + // Whop renamed `sub_status` to `substatus`; accept both spellings so the + // SDK tolerates either, preferring the current one. + $subStatus = $data['substatus'] ?? $data['sub_status'] ?? null; + $createdAt = $data['created_at'] ?? null; return new self( id: $id, status: $status, subStatus: \is_string($subStatus) ? $subStatus : null, - amount: $amount, + total: self::toFloatOrNull($data['total'] ?? null), + subtotal: self::toFloatOrNull($data['subtotal'] ?? null), + usdTotal: self::toFloatOrNull($data['usd_total'] ?? null), + refundedAmount: self::toFloatOrNull($data['refunded_amount'] ?? null), currency: strtoupper($currencyRaw), - applicationFee: \is_int($applicationFee) ? $applicationFee : null, - metadata: self::toStringKeyedArray($metadataRaw), + applicationFee: self::resolveApplicationFee($data), + metadata: self::toStringKeyedArray($data['metadata'] ?? []), createdAt: \is_string($createdAt) ? $createdAt : null, ); } + /** + * Resolve the application fee amount. The current API nests it inside an + * `application_fee` object ({id, amount, ...}); a flat numeric + * `application_fee` and an `application_fee_amount` sibling are also + * accepted for resilience, in that order of precedence. + * + * @param array $data + */ + private static function resolveApplicationFee(array $data): ?float + { + $applicationFee = $data['application_fee'] ?? null; + + if (\is_array($applicationFee)) { + return self::toFloatOrNull($applicationFee['amount'] ?? null); + } + + return self::toFloatOrNull($applicationFee ?? $data['application_fee_amount'] ?? null); + } + + private static function toFloatOrNull(mixed $value): ?float + { + // int values widen to float at the ?float return/property boundary. + return \is_int($value) || \is_float($value) ? $value : null; + } + /** * @return array */ diff --git a/tests/Dto/PaymentTest.php b/tests/Dto/PaymentTest.php index fcf15b0..0932720 100644 --- a/tests/Dto/PaymentTest.php +++ b/tests/Dto/PaymentTest.php @@ -15,125 +15,284 @@ public function testCreateMapsAllFields(): void $payment = Payment::create([ 'id' => 'pay_1', 'status' => 'paid', - 'sub_status' => 'captured', - 'amount' => 2500, + 'substatus' => 'succeeded', + 'total' => 25.5, + 'subtotal' => 24.0, + 'usd_total' => 27.25, + 'refunded_amount' => 5.0, 'currency' => 'usd', - 'application_fee' => 100, + 'application_fee' => ['id' => 'fee_1', 'amount' => 1.25], 'metadata' => ['ref' => 'order_42'], 'created_at' => '2024-01-01T00:00:00Z', ]); self::assertSame('pay_1', $payment->id); self::assertSame('paid', $payment->status); - self::assertSame('captured', $payment->subStatus); - self::assertSame(2500, $payment->amount); + self::assertSame('succeeded', $payment->subStatus); + self::assertSame(25.5, $payment->total); + self::assertSame(24.0, $payment->subtotal); + self::assertSame(27.25, $payment->usdTotal); + self::assertSame(5.0, $payment->refundedAmount); self::assertSame('USD', $payment->currency); - self::assertSame(100, $payment->applicationFee); + self::assertSame(1.25, $payment->applicationFee); self::assertSame(['ref' => 'order_42'], $payment->metadata); self::assertSame('2024-01-01T00:00:00Z', $payment->createdAt); } - public function testCreateFallsBackToApplicationFeeAmountKey(): void + public function testCreateWidensIntegerMonetaryValuesToFloat(): void + { + $payment = Payment::create([ + 'id' => 'pay_int', + 'status' => 'paid', + 'total' => 25, + 'subtotal' => 24, + 'usd_total' => 27, + 'refunded_amount' => 0, + 'currency' => 'USD', + ]); + + self::assertSame(25.0, $payment->total); + self::assertSame(24.0, $payment->subtotal); + self::assertSame(27.0, $payment->usdTotal); + self::assertSame(0.0, $payment->refundedAmount); + } + + public function testCreateReadsSubstatusBeforeLegacySubStatus(): void { $payment = Payment::create([ 'id' => 'pay_2', 'status' => 'paid', - 'amount' => 1000, + 'substatus' => 'current', + 'sub_status' => 'legacy', 'currency' => 'USD', - 'application_fee_amount' => 50, ]); - self::assertSame(50, $payment->applicationFee); + self::assertSame('current', $payment->subStatus); } - public function testCreateNormalizesOptionalFieldDefaults(): void + public function testCreateFallsBackToLegacySubStatusKey(): void { $payment = Payment::create([ 'id' => 'pay_3', + 'status' => 'paid', + 'sub_status' => 'legacy', + 'currency' => 'USD', + ]); + + self::assertSame('legacy', $payment->subStatus); + } + + public function testCreateReadsApplicationFeeFromObjectAmount(): void + { + $payment = Payment::create([ + 'id' => 'pay_4', + 'status' => 'paid', + 'currency' => 'USD', + 'application_fee' => ['id' => 'fee_9', 'amount' => 3.5, 'currency' => 'usd'], + ]); + + self::assertSame(3.5, $payment->applicationFee); + } + + public function testCreateReadsApplicationFeeFromFlatNumericValue(): void + { + $payment = Payment::create([ + 'id' => 'pay_5', + 'status' => 'paid', + 'currency' => 'USD', + 'application_fee' => 2, + ]); + + self::assertSame(2.0, $payment->applicationFee); + } + + public function testCreateFallsBackToApplicationFeeAmountKey(): void + { + $payment = Payment::create([ + 'id' => 'pay_6', + 'status' => 'paid', + 'currency' => 'USD', + 'application_fee_amount' => 4.0, + ]); + + self::assertSame(4.0, $payment->applicationFee); + } + + public function testCreateApplicationFeeObjectTakesPrecedenceOverAmountKey(): void + { + $payment = Payment::create([ + 'id' => 'pay_7', + 'status' => 'paid', + 'currency' => 'USD', + 'application_fee' => ['amount' => 7.0], + 'application_fee_amount' => 99.0, + ]); + + self::assertSame(7.0, $payment->applicationFee); + } + + public function testCreateFlatApplicationFeeTakesPrecedenceOverAmountKey(): void + { + $payment = Payment::create([ + 'id' => 'pay_flat', + 'status' => 'paid', + 'currency' => 'USD', + 'application_fee' => 6, + 'application_fee_amount' => 88, + ]); + + self::assertSame(6.0, $payment->applicationFee); + } + + public function testCreateNullsApplicationFeeWhenObjectHasNoAmount(): void + { + $payment = Payment::create([ + 'id' => 'pay_8', + 'status' => 'paid', + 'currency' => 'USD', + 'application_fee' => ['id' => 'fee_only'], + ]); + + self::assertNull($payment->applicationFee); + } + + public function testCreateNormalizesOptionalFieldDefaults(): void + { + $payment = Payment::create([ + 'id' => 'pay_9', 'status' => 'pending', - 'amount' => 0, - 'currency' => 'EUR', + 'currency' => 'eur', ]); self::assertNull($payment->subStatus); - self::assertSame(0, $payment->amount); - self::assertSame('EUR', $payment->currency); + self::assertNull($payment->total); + self::assertNull($payment->subtotal); + self::assertNull($payment->usdTotal); + self::assertNull($payment->refundedAmount); self::assertNull($payment->applicationFee); + self::assertSame('EUR', $payment->currency); self::assertSame([], $payment->metadata); self::assertNull($payment->createdAt); } + public function testCreateNullsNonNumericMonetaryValues(): void + { + $payment = Payment::create([ + 'id' => 'pay_10', + 'status' => 'paid', + 'currency' => 'USD', + 'total' => '25.50', + 'subtotal' => null, + ]); + + self::assertNull($payment->total); + self::assertNull($payment->subtotal); + } + + public function testCreateNullsNonStringSubStatus(): void + { + $payment = Payment::create([ + 'id' => 'pay_11', + 'status' => 'paid', + 'currency' => 'USD', + 'substatus' => 123, + ]); + + self::assertNull($payment->subStatus); + } + + public function testCreateNullsNonStringCreatedAt(): void + { + $payment = Payment::create([ + 'id' => 'pay_12', + 'status' => 'paid', + 'currency' => 'USD', + 'created_at' => 1700000000, + ]); + + self::assertNull($payment->createdAt); + } + public function testCreateUppercasesCurrency(): void { $payment = Payment::create([ - 'id' => 'pay_4', + 'id' => 'pay_13', 'status' => 'paid', - 'amount' => 500, 'currency' => 'gbp', ]); self::assertSame('GBP', $payment->currency); } - public function testCreateApplicationFeeKeyTakesPrecedence(): void + public function testCreateFiltersNonStringMetadataKeys(): void { $payment = Payment::create([ - 'id' => 'pay_5', + 'id' => 'pay_14', 'status' => 'paid', - 'amount' => 2000, 'currency' => 'USD', - 'application_fee' => 75, - 'application_fee_amount' => 25, + 'metadata' => ['keep' => 'yes', 0 => 'drop'], ]); - self::assertSame(75, $payment->applicationFee); + self::assertSame(['keep' => 'yes'], $payment->metadata); } - public function testCreateThrowsWhenIdMissing(): void + public function testCreatePreservesMultipleStringMetadataKeys(): void { - $this->expectException(MissingArgumentsException::class); - Payment::create(['status' => 'paid', 'amount' => 100, 'currency' => 'USD']); + $payment = Payment::create([ + 'id' => 'pay_meta', + 'status' => 'paid', + 'currency' => 'USD', + 'metadata' => ['a' => '1', 'b' => '2'], + ]); + + self::assertSame(['a' => '1', 'b' => '2'], $payment->metadata); } - public function testCreateThrowsWhenIdEmpty(): void + public function testCreateDefaultsMetadataToEmptyArrayWhenNotArray(): void { - $this->expectException(MissingArgumentsException::class); - Payment::create(['id' => '', 'status' => 'paid', 'amount' => 100, 'currency' => 'USD']); + $payment = Payment::create([ + 'id' => 'pay_15', + 'status' => 'paid', + 'currency' => 'USD', + 'metadata' => 'not-an-array', + ]); + + self::assertSame([], $payment->metadata); } - public function testCreateThrowsWhenStatusMissing(): void + public function testCreateThrowsWhenIdMissing(): void { $this->expectException(MissingArgumentsException::class); - Payment::create(['id' => 'pay_6', 'amount' => 100, 'currency' => 'USD']); + Payment::create(['status' => 'paid', 'currency' => 'USD']); } - public function testCreateThrowsWhenStatusEmpty(): void + public function testCreateThrowsWhenIdEmpty(): void { $this->expectException(MissingArgumentsException::class); - Payment::create(['id' => 'pay_6', 'status' => '', 'amount' => 100, 'currency' => 'USD']); + Payment::create(['id' => '', 'status' => 'paid', 'currency' => 'USD']); } - public function testCreateThrowsWhenAmountMissing(): void + public function testCreateThrowsWhenStatusMissing(): void { $this->expectException(MissingArgumentsException::class); - Payment::create(['id' => 'pay_7', 'status' => 'paid', 'currency' => 'USD']); + Payment::create(['id' => 'pay_16', 'currency' => 'USD']); } - public function testCreateThrowsWhenAmountWrongType(): void + public function testCreateThrowsWhenStatusEmpty(): void { $this->expectException(MissingArgumentsException::class); - Payment::create(['id' => 'pay_7', 'status' => 'paid', 'amount' => '100', 'currency' => 'USD']); + Payment::create(['id' => 'pay_16', 'status' => '', 'currency' => 'USD']); } public function testCreateThrowsWhenCurrencyMissing(): void { $this->expectException(MissingArgumentsException::class); - Payment::create(['id' => 'pay_8', 'status' => 'paid', 'amount' => 100]); + Payment::create(['id' => 'pay_17', 'status' => 'paid']); } public function testCreateThrowsWhenCurrencyEmpty(): void { $this->expectException(MissingArgumentsException::class); - Payment::create(['id' => 'pay_8', 'status' => 'paid', 'amount' => 100, 'currency' => '']); + Payment::create(['id' => 'pay_17', 'status' => 'paid', 'currency' => '']); } } From 051dc6cd2a648db2857883cf05db3922029a87fa Mon Sep 17 00:00:00 2001 From: Bakhtarian Date: Tue, 2 Jun 2026 12:05:32 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(payment):=20review=20round=202=20?= =?UTF-8?q?=E2=80=94=20current-schema=20resource=20fixture=20+=20widen=20R?= =?UTF-8?q?efundResponse=20amount=20to=20=3Ffloat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PaymentResourceTest: replace removed `amount` fixture with `total`, assert it survives resource->DTO path - RefundResponse: $amount ?int -> ?float so fractional refund amounts aren't dropped (+ tests) --- CHANGELOG.md | 3 +++ src/Dto/Payment/RefundResponse.php | 4 ++-- tests/Dto/RefundResponseTest.php | 22 +++++++++++++++++++++- tests/Resource/PaymentResourceTest.php | 3 ++- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa27382..2e8748a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and then `application_fee_amount`. Its type changed from `?int` to `?float`. - `Payment::$subStatus` is now read from `substatus` (the current field name), falling back to the legacy `sub_status`. +- `RefundResponse::$amount` changed from `?int` to `?float`, so fractional + refund amounts (the API returns `number`) are no longer silently coerced to + `null` by an int-only type guard. ## [0.0.1] - 2026-05-15 diff --git a/src/Dto/Payment/RefundResponse.php b/src/Dto/Payment/RefundResponse.php index 94ccceb..0977fb4 100644 --- a/src/Dto/Payment/RefundResponse.php +++ b/src/Dto/Payment/RefundResponse.php @@ -12,7 +12,7 @@ private function __construct( public string $refundId, public ?string $paymentId, public ?string $status, - public ?int $amount, + public ?float $amount, ) { } @@ -35,7 +35,7 @@ public static function fromResponse(array $data): self refundId: $refundId, paymentId: \is_string($paymentId) ? $paymentId : null, status: \is_string($status) ? $status : null, - amount: \is_int($amount) ? $amount : null, + amount: \is_int($amount) || \is_float($amount) ? $amount : null, ); } } diff --git a/tests/Dto/RefundResponseTest.php b/tests/Dto/RefundResponseTest.php index bc9adbd..99c9772 100644 --- a/tests/Dto/RefundResponseTest.php +++ b/tests/Dto/RefundResponseTest.php @@ -22,7 +22,7 @@ public function testFromResponseMapsAllFields(): void self::assertSame('ref_1', $refund->refundId); self::assertSame('pay_1', $refund->paymentId); self::assertSame('succeeded', $refund->status); - self::assertSame(1000, $refund->amount); + self::assertSame(1000.0, $refund->amount); } public function testFromResponseFallsBackToIdKey(): void @@ -53,6 +53,26 @@ public function testFromResponseDefaults(): void self::assertNull($refund->amount); } + public function testFromResponsePreservesFractionalAmount(): void + { + $refund = RefundResponse::fromResponse([ + 'refund_id' => 'ref_frac', + 'amount' => 25.5, + ]); + + self::assertSame(25.5, $refund->amount); + } + + public function testFromResponseNullsNonNumericAmount(): void + { + $refund = RefundResponse::fromResponse([ + 'refund_id' => 'ref_str', + 'amount' => '1000', + ]); + + self::assertNull($refund->amount); + } + public function testFromResponseThrowsWhenBothIdsMissing(): void { $this->expectException(MissingArgumentsException::class); diff --git a/tests/Resource/PaymentResourceTest.php b/tests/Resource/PaymentResourceTest.php index 1aa699b..dfcead5 100644 --- a/tests/Resource/PaymentResourceTest.php +++ b/tests/Resource/PaymentResourceTest.php @@ -62,7 +62,7 @@ public function testListPassesQuery(): void public function testGetReturnsPaymentDto(): void { - $this->http->willReturn(200, '{"id":"pay_1","status":"paid","amount":2500,"currency":"USD"}'); + $this->http->willReturn(200, '{"id":"pay_1","status":"paid","total":25.0,"currency":"USD"}'); $payment = $this->resource->get('pay_1'); @@ -72,6 +72,7 @@ public function testGetReturnsPaymentDto(): void self::assertSame('https://api.whop.com/api/v1/payments/pay_1', (string) $req->getUri()); self::assertInstanceOf(Payment::class, $payment); self::assertSame('pay_1', $payment->id); + self::assertSame(25.0, $payment->total); } public function testRefundWithoutAmountSendsEmptyBody(): void