diff --git a/.gitignore b/.gitignore index b36c5239..a1f3ea18 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,11 @@ composer.phar test/unit/_html PrivateKey.key + +# Local development +.lando.yml + BitPay.config.json **/BitPay.config.json BitPay.config.yml -**/BitPay.config.yml \ No newline at end of file +**/BitPay.config.yml diff --git a/composer.json b/composer.json index 300e046a..4b29b6d7 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ "guzzlehttp/guzzle": "^7.9", "symfony/yaml": "^5.4 || ^6.4 || ^7.0", "netresearch/jsonmapper": "^5.0", - "symfony/console": "^4.4 || ^5.4 || ^6.4" + "symfony/console": "^4.4 || ^5.4 || ^6.4 || ^7.3.1" }, "authors": [ { @@ -48,4 +48,4 @@ "BitPaySDK\\Functional\\": "test/functional/BitPaySDK" } } -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index d0ceeeb7..35054722 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "196bfb0a01807670ed28b1127c96190d", + "content-hash": "5f183a3894282445a07d9c4734f79e25", "packages": [ { "name": "bitpay/key-utils", diff --git a/src/BitPaySDK/Client.php b/src/BitPaySDK/Client.php index cb91d1e2..7f280a40 100644 --- a/src/BitPaySDK/Client.php +++ b/src/BitPaySDK/Client.php @@ -18,6 +18,7 @@ use BitPaySDK\Client\RateClient; use BitPaySDK\Client\RefundClient; use BitPaySDK\Client\SettlementClient; +use BitPaySDK\Client\SubscriptionClient; use BitPaySDK\Client\TokenClient; use BitPaySDK\Client\WalletClient; use BitPaySDK\Exceptions\BitPayApiException; @@ -36,6 +37,7 @@ use BitPaySDK\Model\Rate\Rate; use BitPaySDK\Model\Rate\Rates; use BitPaySDK\Model\Settlement\Settlement; +use BitPaySDK\Model\Subscription\Subscription; use BitPaySDK\Model\Wallet\Wallet; use BitPaySDK\Util\RESTcli\RESTcli; use Exception; @@ -576,7 +578,7 @@ public function getBill(string $billId, string $facade = Facade::MERCHANT, bool * * @see https://developer.bitpay.com/reference/retrieve-bills-by-status Retrieve Bills by Status * - * @param string|null The status to filter the bills. + * @param string|null $status The status to filter the bills. * @return Bill[] * @throws BitPayApiException * @throws BitPayGenericException @@ -625,6 +627,75 @@ public function deliverBill(string $billId, string $billToken, bool $signRequest return $billClient->deliver($billId, $billToken, $signRequest); } + /** + * Create a BitPay Subscription. + * + * @see https://developer.bitpay.com/reference/create-a-subscription Create a Subscription + * + * @param Subscription $subscription A Subscription object with request parameters defined. + * @return Subscription Created Subscription object + * @throws BitPayApiException + * @throws BitPayGenericException + */ + public function createSubscription(Subscription $subscription): Subscription + { + $subscriptionClient = $this->getSubscriptionClient(); + + return $subscriptionClient->create($subscription); + } + + /** + * Retrieve a BitPay subscription by its ID. + * + * @see https://developer.bitpay.com/reference/retrieve-a-subscription Retrieve a Subscription + * + * @param string $subscriptionId The ID of the subscription to retrieve. + * @return Subscription + * @throws BitPayApiException + * @throws BitPayGenericException + */ + public function getSubscription(string $subscriptionId): Subscription + { + $subscriptionClient = $this->getSubscriptionClient(); + + return $subscriptionClient->get($subscriptionId); + } + + /** + * Retrieve a collection of BitPay subscriptions. + * + * @see https://developer.bitpay.com/reference/retrieve-subscriptions-by-status Retrieve Subscriptions by Status + * + * @param string|null $status The status on which to filter the subscriptions. + * @return Subscription[] Filtered list of Subscription objects + * @throws BitPayApiException + * @throws BitPayGenericException + */ + public function getSubscriptions(?string $status = null): array + { + $subscriptionClient = $this->getSubscriptionClient(); + + return $subscriptionClient->getSubscriptions($status); + } + + /** + * Update a BitPay Subscription. + * + * @see https://developer.bitpay.com/reference/update-a-subscription Update a Subscription + * + * @param Subscription $subscription A Subscription object with the parameters to update defined. + * @param string $subscriptionId The ID of the Subscription to update. + * @return Subscription Updated Subscription object + * @throws BitPayApiException + * @throws BitPayGenericException + */ + public function updateSubscription(Subscription $subscription, string $subscriptionId): Subscription + { + $subscriptionClient = $this->getSubscriptionClient(); + + return $subscriptionClient->update($subscription, $subscriptionId); + } + /** * Retrieve the exchange rate table maintained by BitPay. * @see https://bitpay.com/bitcoin-exchange-rates @@ -1113,6 +1184,16 @@ protected function getBillClient(): BillClient return BillClient::getInstance($this->tokenCache, $this->restCli); } + /** + * Gets subscription client + * + * @return SubscriptionClient the subscription client + */ + protected function getSubscriptionClient(): SubscriptionClient + { + return SubscriptionClient::getInstance($this->tokenCache, $this->restCli); + } + /** * Gets rate client * diff --git a/src/BitPaySDK/Client/SubscriptionClient.php b/src/BitPaySDK/Client/SubscriptionClient.php new file mode 100644 index 00000000..5588d33d --- /dev/null +++ b/src/BitPaySDK/Client/SubscriptionClient.php @@ -0,0 +1,172 @@ + + * @license http://www.opensource.org/licenses/mit-license.php MIT + */ +class SubscriptionClient +{ + private static ?self $instance = null; + private Tokens $tokenCache; + private RESTcli $restCli; + + private function __construct(Tokens $tokenCache, RESTcli $restCli) + { + $this->tokenCache = $tokenCache; + $this->restCli = $restCli; + } + + /** + * Factory method for Subscription Client. + * + * @param Tokens $tokenCache + * @param RESTcli $restCli + * @return static + */ + public static function getInstance(Tokens $tokenCache, RESTcli $restCli): self + { + if (!self::$instance) { + self::$instance = new self($tokenCache, $restCli); + } + + return self::$instance; + } + + /** + * Create a BitPay Subscription. + * + * @param Subscription $subscription A Subscription object with request parameters defined. + * @return Subscription Created Subscription object + * @throws BitPayApiException + * @throws BitPayGenericException + */ + public function create(Subscription $subscription): Subscription + { + $subscription->setToken($this->tokenCache->getTokenByFacade(Facade::MERCHANT)); + + $responseJson = $this->restCli->post("subscriptions", $subscription->toArray()); + + try { + return $this->mapJsonToSubscriptionClass($responseJson); + } catch (Exception $e) { + BitPayExceptionProvider::throwDeserializeResourceException('Subscription', $e->getMessage()); + } + } + + /** + * Retrieve a BitPay subscription by its resource ID. + * + * @param $subscriptionId string The id of the subscription to retrieve. + * @return Subscription Retrieved Subscription object + * @throws BitPayApiException + * @throws BitPayGenericException + */ + public function get(string $subscriptionId): Subscription + { + $params = []; + $params["token"] = $this->tokenCache->getTokenByFacade(Facade::MERCHANT); + + $responseJson = $this->restCli->get("subscriptions/" . $subscriptionId, $params); + + try { + return $this->mapJsonToSubscriptionClass($responseJson); + } catch (Exception $e) { + BitPayExceptionProvider::throwDeserializeResourceException('Subscription', $e->getMessage()); + } + } + + /** + * Retrieve a collection of BitPay subscriptions. + * + * @param string|null $status The status to filter the subscriptions. + * @return Subscription[] Filtered list of Subscription objects + * @throws BitPayApiException + * @throws BitPayGenericException + */ + public function getSubscriptions(?string $status = null): array + { + $params = []; + $params["token"] = $this->tokenCache->getTokenByFacade(Facade::MERCHANT); + if ($status) { + $params["status"] = $status; + } + + $responseJson = $this->restCli->get("subscriptions", $params); + + try { + $mapper = JsonMapperFactory::create(); + return $mapper->mapArray( + json_decode($responseJson, true, 512, JSON_THROW_ON_ERROR), + [], + Subscription::class + ); + } catch (Exception $e) { + BitPayExceptionProvider::throwDeserializeResourceException('Subscription', $e->getMessage()); + } + } + + /** + * Update a BitPay Subscription. + * + * @param Subscription $subscription A Subscription object with the parameters to update defined. + * @param string $subscriptionId The ID of the Subscription to update. + * @return Subscription Updated Subscription object + * @throws BitPayApiException + * @throws BitPayGenericException + */ + public function update(Subscription $subscription, string $subscriptionId): Subscription + { + $subscriptionToken = $this->get($subscription->getId())->getToken(); + $subscription->setToken($subscriptionToken); + + $responseJson = $this->restCli->update("subscriptions/" . $subscriptionId, $subscription->toArray()); + + try { + $mapper = JsonMapperFactory::create(); + + return $mapper->map( + json_decode($responseJson, true, 512, JSON_THROW_ON_ERROR), + $subscription + ); + } catch (Exception $e) { + BitPayExceptionProvider::throwDeserializeResourceException('Subscription', $e->getMessage()); + } + } + + /** + * @param string|null $responseJson + * @return Subscription + * @throws \JsonException + * @throws \JsonMapper_Exception + */ + private function mapJsonToSubscriptionClass(?string $responseJson): Subscription + { + $mapper = JsonMapperFactory::create(); + + return $mapper->map( + json_decode($responseJson, true, 512, JSON_THROW_ON_ERROR), + new Subscription() + ); + } +} diff --git a/src/BitPaySDK/Env.php b/src/BitPaySDK/Env.php index ef0206fa..06b0a956 100644 --- a/src/BitPaySDK/Env.php +++ b/src/BitPaySDK/Env.php @@ -20,4 +20,5 @@ interface Env public const BITPAY_PLUGIN_INFO = "BitPay_PHP_Client_v9.2.2"; public const BITPAY_API_FRAME = "std"; public const BITPAY_API_FRAME_VERSION = "1.0.0"; + public const BITPAY_DATETIME_FORMAT = 'Y-m-d\TH:i:s\Z'; } diff --git a/src/BitPaySDK/Model/Settlement/Settlement.php b/src/BitPaySDK/Model/Settlement/Settlement.php index 43d0204b..9c95ce0d 100644 --- a/src/BitPaySDK/Model/Settlement/Settlement.php +++ b/src/BitPaySDK/Model/Settlement/Settlement.php @@ -141,6 +141,7 @@ public function setPayoutInfo(PayoutInfo $payoutInfo): void * Gets Status of the settlement. Possible statuses are "new", "processing", "rejected" and "completed". * * @return string|null + * @see SettlementStatus */ public function getStatus(): ?string { @@ -151,6 +152,7 @@ public function getStatus(): ?string * Sets Status of the settlement. Possible statuses are "new", "processing", "rejected" and "completed". * * @param string $status + * @see SettlementStatus */ public function setStatus(string $status): void { diff --git a/src/BitPaySDK/Model/Settlement/SettlementStatus.php b/src/BitPaySDK/Model/Settlement/SettlementStatus.php new file mode 100644 index 00000000..e848653a --- /dev/null +++ b/src/BitPaySDK/Model/Settlement/SettlementStatus.php @@ -0,0 +1,29 @@ + + * @license http://www.opensource.org/licenses/mit-license.php MIT + */ + +namespace BitPaySDK\Model\Settlement; + +/** + * Status of the settlement. + * Possible statuses are "new", "processing", "rejected" and "completed". + * + * @package BitPaySDK\Model\Settlement + * @author BitPay Integrations + * @license http://www.opensource.org/licenses/mit-license.php MIT + * @see https://developer.bitpay.com/reference/settlements Settlements + */ +interface SettlementStatus +{ + public const NEW = "new"; + public const PROCESSING = "processing"; + public const REJECTED = "rejected"; + public const COMPLETED = "completed"; +} diff --git a/src/BitPaySDK/Model/Subscription/Subscription.php b/src/BitPaySDK/Model/Subscription/Subscription.php new file mode 100644 index 00000000..50fd2347 --- /dev/null +++ b/src/BitPaySDK/Model/Subscription/Subscription.php @@ -0,0 +1,268 @@ + + * @license http://www.opensource.org/licenses/mit-license.php MIT + */ +class Subscription +{ + protected ?string $id = null; + protected ?string $status = null; + protected Bill $billData; + protected ?string $merchant = null; + protected ?string $schedule = null; + protected ?string $nextDelivery = null; + protected ?string $createdDate = null; + protected ?string $token = null; + + /** + * Constructor, create a minimal request Subscription object. + * + * @param Bill|null $billData Object containing the recurring billing information. + * @param string|null $schedule Schedule of recurring billing due dates + */ + public function __construct(?Bill $billData = null, ?string $schedule = SubscriptionSchedule::MONTHLY) + { + $this->billData = $billData ?: new Bill(); + $this->schedule = $schedule; + } + + /** + * Get subscription data as array + * + * @return array subscription data as array + */ + public function toArray(): array + { + $elements = [ + 'id' => $this->getId(), + 'status' => $this->getStatus(), + 'billData' => $this->getBillData()->toArray(), + 'merchant' => $this->getMerchant(), + 'schedule' => $this->getSchedule(), + 'nextDelivery' => $this->getNextDelivery(), + 'createdDate' => $this->getCreatedDate(), + 'token' => $this->getToken(), + ]; + + foreach ($elements as $key => $value) { + if (empty($value)) { + unset($elements[$key]); + } + } + + return $elements; + } + + /** + * Get Subscription id + * + * Subscription resource id + * + * @return string|null the id + */ + public function getId(): ?string + { + return $this->id; + } + + /** + * Set Subscription id + * + * Subscription resource id + * + * @param string $id Subscription resource id + */ + public function setId(string $id): void + { + $this->id = $id; + } + + /** + * Get Subscription status + * + * @return string|null the status + * @see SubscriptionStatus + * + */ + public function getStatus(): ?string + { + return $this->status; + } + + /** + * Set Subscription status + * + * @param string $status Subscription's status + * @see SubscriptionStatus + * + */ + public function setStatus(string $status): void + { + $this->status = $status; + } + + /** + * Get Subscription billData + * + * Object containing the recurring billing information + * + * @return Bill + */ + public function getBillData(): Bill + { + return $this->billData; + } + + /** + * Set Subscription billData + * + * @param Bill $billData Object containing the recurring billing information. + * @return void + */ + public function setBillData(Bill $billData): void + { + $this->billData = $billData; + } + + /** + * Get Subscription merchant + * + * Internal identifier for BitPay, this field can be ignored by the merchants. + * + * @return string|null the merchant + */ + public function getMerchant(): ?string + { + return $this->merchant; + } + + /** + * Set Subscription merchant + * + * Internal identifier for BitPay, this field can be ignored by the merchants. + * + * @param string $merchant Internal identifier for BitPay + */ + public function setMerchant(string $merchant): void + { + $this->merchant = $merchant; + } + + /** + * Get Subscription created date + * + * Date and time of Subscription creation, ISO-8601 format yyyy-mm-ddThh:mm:ssZ. (UTC) + * + * @return string|null the created date + * @see Env::BITPAY_DATETIME_FORMAT + */ + public function getCreatedDate(): ?string + { + return $this->createdDate; + } + + /** + * Set Subscription created date + * + * Date and time of Subscription creation, ISO-8601 format yyyy-mm-ddThh:mm:ssZ. (UTC) + * + * @param string $createdDate Subscription's created date + * @see Env::BITPAY_DATETIME_FORMAT + */ + public function setCreatedDate(string $createdDate): void + { + $this->createdDate = $createdDate; + } + + /** + * Gets token + * + * API token for subscription resource. This token is actually derived from the API token used to create the + * subscription and is tied to the specific resource id created. + * + * @return string|null the token + */ + public function getToken(): ?string + { + return $this->token; + } + + /** + * Set Subscription token + * + * API token for subscription resource. This token is actually derived from the API token used to create the + * subscription and is tied to the specific resource id created. + * + * @param string $token API token for subscription resource + */ + public function setToken(string $token): void + { + $this->token = $token; + } + + /** + * Get Subscription schedule + * + * @return string|null + * @see SubscriptionSchedule + * + */ + public function getSchedule(): ?string + { + return $this->schedule; + } + + /** + * Set Subscription schedule + * + * @param string $schedule + * @return void + * @see SubscriptionSchedule + * + */ + public function setSchedule(string $schedule): void + { + $this->schedule = $schedule; + } + + /** + * Get Subscription's next delivery date + * + * Default is current date & time, ISO-8601 format yyyy-mm-ddThh:mm:ssZ (UTC). Current or past date indicates that + * the bill can be delivered immediately. BitPay may modify the hh:mm:ss values in order to distribute deliveries + * evenly throughout the day. + * + * @return string|null Subscription's next delivery date + */ + public function getNextDelivery(): ?string + { + return $this->nextDelivery; + } + + /** + * Set Subscription's next delivery date + * + * Default is current date & time, ISO-8601 format yyyy-mm-ddThh:mm:ssZ (UTC). Current or past date indicates that + * the bill can be delivered immediately. BitPay may modify the hh:mm:ss values in order to distribute deliveries + * evenly throughout the day. + * + * @param string $nextDelivery Subscription's next delivery date + * @return void + */ + public function setNextDelivery(string $nextDelivery): void + { + $this->nextDelivery = $nextDelivery; + } +} diff --git a/src/BitPaySDK/Model/Subscription/SubscriptionSchedule.php b/src/BitPaySDK/Model/Subscription/SubscriptionSchedule.php new file mode 100644 index 00000000..92c75a18 --- /dev/null +++ b/src/BitPaySDK/Model/Subscription/SubscriptionSchedule.php @@ -0,0 +1,46 @@ + + * @license http://www.opensource.org/licenses/mit-license.php MIT + */ + +namespace BitPaySDK\Model\Subscription; + +/** + * Schedule of repeat bill due dates. Can be `weekly`, `monthly`, `quarterly`, `yearly`, or a simple cron expression + * specifying seconds, minutes, hours, day of month, month, and day of week. BitPay maintains the difference between + * the due date and the delivery date in all subsequent, automatically-generated bills. + * + * +-------------- second (0 - 59) + * + * | +------------ minute (0 - 59) + * + * | | +---------- hour (0 - 23) + * + * | | | +-------- day of month (1 - 31) + * + * | | | | +------ month (1 - 12) + * + * | | | | | +---- day of week (0 - 6) (Sunday=0 or 7) + * + * | | | | | | + * + * \* * * * * Cron expression + * + * @package BitPaySDK\Model\Subscription + * @author BitPay Integrations + * @license http://www.opensource.org/licenses/mit-license.php MIT + * @see https://developer.bitpay.com/reference/subscriptions Subscriptions + */ +interface SubscriptionSchedule +{ + public const WEEKLY = "weekly"; + public const MONTHLY = "monthly"; + public const QUARTERLY = "quarterly"; + public const YEARLY = "yearly"; +} diff --git a/src/BitPaySDK/Model/Subscription/SubscriptionStatus.php b/src/BitPaySDK/Model/Subscription/SubscriptionStatus.php new file mode 100644 index 00000000..5c08977f --- /dev/null +++ b/src/BitPaySDK/Model/Subscription/SubscriptionStatus.php @@ -0,0 +1,28 @@ + + * @license http://www.opensource.org/licenses/mit-license.php MIT + */ + +namespace BitPaySDK\Model\Subscription; + +/** + * Subscription object status. Can be `draft`, `active` or `cancelled`. + * Subscriptions in `active` state will create new Bills on the `nextDelivery` date. + * + * @package BitPaySDK\Model\Subscription + * @author BitPay Integrations + * @license http://www.opensource.org/licenses/mit-license.php MIT + * @see https://developer.bitpay.com/reference/subscriptions Subscriptions + */ +interface SubscriptionStatus +{ + public const DRAFT = "draft"; + public const ACTIVE = "active"; + public const CANCELLED = "cancelled"; +} diff --git a/test/functional/BitPaySDK/SubscriptionClientTest.php b/test/functional/BitPaySDK/SubscriptionClientTest.php new file mode 100644 index 00000000..55784d7c --- /dev/null +++ b/test/functional/BitPaySDK/SubscriptionClientTest.php @@ -0,0 +1,173 @@ +getSubscriptionExample(); + $subscription = $this->client->createSubscription($subscription); + + self::assertEquals(SubscriptionStatus::DRAFT, $subscription->getStatus()); + + $this->assertValidMonthlySchedule($subscription->getSchedule()); + + self::assertEquals(3.0, $subscription->getBillData()->getItems()[0]->getPrice()); + self::assertEquals(2, $subscription->getBillData()->getItems()[0]->getQuantity()); + self::assertEquals(1, $subscription->getBillData()->getItems()[1]->getQuantity()); + self::assertEquals(7.0, $subscription->getBillData()->getItems()[1]->getPrice()); + self::assertEquals("Test Item 1", $subscription->getBillData()->getItems()[0]->getDescription()); + self::assertEquals("Test Item 2", $subscription->getBillData()->getItems()[1]->getDescription()); + self::assertEquals(Currency::USD, $subscription->getBillData()->getCurrency()); + } + + public function testGetSubscription(): void + { + $subscription = $this->getSubscriptionExample(); + $subscription = $this->client->createSubscription($subscription); + $subscription = $this->client->getSubscription($subscription->getId()); + + self::assertEquals(SubscriptionStatus::DRAFT, $subscription->getStatus()); + + $this->assertValidMonthlySchedule($subscription->getSchedule()); + + self::assertCount(2, $subscription->getBillData()->getItems()); + self::assertEquals(Currency::USD, $subscription->getBillData()->getCurrency()); + self::assertEquals('billData1234-ABCD', $subscription->getBillData()->getNumber()); + self::assertEquals('john.doe@example.com', $subscription->getBillData()->getEmail()); + } + + public function testGetSubscriptions(): void + { + $subscriptions = $this->client->getSubscriptions(); + + self::assertNotNull($subscriptions); + self::assertIsArray($subscriptions); + $isCount = count($subscriptions) > 0; + self::assertTrue($isCount); + } + + public function testUpdateSubscription(): void + { + $subscription = $this->getSubscriptionExample(); + $subscription = $this->client->createSubscription($subscription); + + // Store original values for comparison after update + $originalId = $subscription->getId(); + $originalStatus = $subscription->getStatus(); + $originalCurrency = $subscription->getBillData()->getCurrency(); + $originalNumber = $subscription->getBillData()->getNumber(); + + $subscription = $this->client->getSubscription($subscription->getId()); + + // Update multiple fields + $bill = $subscription->getBillData(); + $bill->setEmail("jane.doe@example.com"); + $bill->setName("Updated Company Name"); + $bill->setCC(["jane.doe@example.com"]); + $bill->setPhone("555-0100"); + + // Update an item price + $items = $bill->getItems(); + $items[0]->setPrice(5.0); // Change first item price from 3.0 to 5.0 + $bill->setItems($items); + + $subscription->setBillData($bill); + + $subscription = $this->client->updateSubscription($subscription, $subscription->getId()); + + // Assert updated fields + self::assertEquals("jane.doe@example.com", $subscription->getBillData()->getEmail()); + self::assertEquals("Updated Company Name", $subscription->getBillData()->getName()); + self::assertEquals(["jane.doe@example.com"], $subscription->getBillData()->getCc()); + self::assertEquals("555-0100", $subscription->getBillData()->getPhone()); + self::assertEquals(5.0, $subscription->getBillData()->getItems()[0]->getPrice()); + + // Assert that other important fields weren't changed + self::assertEquals($originalId, $subscription->getId(), "Subscription ID should not change after update"); + self::assertEquals($originalStatus, $subscription->getStatus(), "Subscription status should not change"); + self::assertEquals($originalCurrency, $subscription->getBillData()->getCurrency(), "Currency should not change"); + self::assertEquals($originalNumber, $subscription->getBillData()->getNumber(), "Bill number should be preserved"); + self::assertEquals(2, count($subscription->getBillData()->getItems()), "Item count should be preserved"); + self::assertEquals(2, $subscription->getBillData()->getItems()[0]->getQuantity(), "Item quantity should be preserved"); + } + + private function getSubscriptionExample(): Subscription + { + return new Subscription($this->getBillDataExample()); + } + + private function getBillDataExample(): Bill + { + $items = []; + $item = new Item(); + $item->setPrice(3.0); + $item->setQuantity(2); + $item->setDescription("Test Item 1"); + $items[] = $item; + + $item = new Item(); + $item->setPrice(7.0); + $item->setQuantity(1); + $item->setDescription("Test Item 2"); + $items[] = $item; + + $bill = new Bill("billData1234-ABCD", Currency::USD, "john.doe@example.com", $items); + + $dueDate = (new \DateTime('first day of next month')); + $bill->setDueDate($dueDate->format('Y-m-d\TH:i:s\Z')); + + return $bill; + } + + /** + * Helper method to validate if a schedule is a valid monthly schedule (either 'monthly' or a cron expression) + * + * @param string $schedule The schedule to validate + */ + private function assertValidMonthlySchedule(string $schedule): void + { + if ($schedule === SubscriptionSchedule::MONTHLY) { + return; // Standard monthly string is valid + } + + $parts = explode(' ', $schedule); + + // A proper cron expression should have 6 parts: second minute hour dayOfMonth month dayOfWeek + if (count($parts) !== 6) { + self::fail("Invalid cron expression format: " . $schedule); + } + + [$second, $minute, $hour, $dayOfMonth, $month, $dayOfWeek] = $parts; + + // Validate time parts are within range + self::assertGreaterThanOrEqual(0, (int)$second, "Second must be ≥ 0: " . $schedule); + self::assertLessThanOrEqual(59, (int)$second, "Second must be ≤ 59: " . $schedule); + + self::assertGreaterThanOrEqual(0, (int)$minute, "Minute must be ≥ 0: " . $schedule); + self::assertLessThanOrEqual(59, (int)$minute, "Minute must be ≤ 59: " . $schedule); + + self::assertGreaterThanOrEqual(0, (int)$hour, "Hour must be ≥ 0: " . $schedule); + self::assertLessThanOrEqual(23, (int)$hour, "Hour must be ≤ 23: " . $schedule); + + self::assertGreaterThanOrEqual(1, (int)$dayOfMonth, "Day of month must be ≥ 1: " . $schedule); + self::assertLessThanOrEqual(28, (int)$dayOfMonth, "Day of month must be ≤ 28: " . $schedule); + + self::assertEquals('*', $month, "Month must be * for monthly schedule: " . $schedule); + self::assertEquals('*', $dayOfWeek, "Day of week must be * for monthly schedule: " . $schedule); + } +} diff --git a/test/unit/BitPaySDK/Model/Subscription/ItemTest.php b/test/unit/BitPaySDK/Model/Subscription/ItemTest.php new file mode 100644 index 00000000..a99ae564 --- /dev/null +++ b/test/unit/BitPaySDK/Model/Subscription/ItemTest.php @@ -0,0 +1,59 @@ +assertInstanceOf(Item::class, $item); + } + + public function testPriceGetterSetter(): void + { + $item = new Item(); + $item->setPrice(10.5); + $this->assertEquals(10.5, $item->getPrice()); + } + + public function testQuantityGetterSetter(): void + { + $item = new Item(); + $item->setQuantity(5); + $this->assertEquals(5, $item->getQuantity()); + } + + public function testDescriptionGetterSetter(): void + { + $item = new Item(); + $item->setDescription('Test Item'); + $this->assertEquals('Test Item', $item->getDescription()); + } + + public function testToArray(): void + { + $item = new Item(); + $item->setPrice(10.5); + $item->setQuantity(5); + $item->setDescription('Test Item'); + + $result = $item->toArray(); + + $this->assertArrayHasKey('price', $result); + $this->assertArrayHasKey('quantity', $result); + $this->assertArrayHasKey('description', $result); + $this->assertEquals(10.5, $result['price']); + $this->assertEquals(5, $result['quantity']); + $this->assertEquals('Test Item', $result['description']); + } +} diff --git a/test/unit/BitPaySDK/Model/Subscription/SubscriptionTest.php b/test/unit/BitPaySDK/Model/Subscription/SubscriptionTest.php new file mode 100644 index 00000000..da799f4c --- /dev/null +++ b/test/unit/BitPaySDK/Model/Subscription/SubscriptionTest.php @@ -0,0 +1,94 @@ +assertInstanceOf(Subscription::class, $subscription); + } + + public function testConstructorWithBill(): void + { + $items = []; + $item = new Item(); + $item->setPrice(10.0); + $item->setQuantity(1); + $items[] = $item; + + $bill = new Bill('testNumber', 'USD', 'test@example.com', $items); + $dueDate = (new \DateTime('now'))->format('Y-m-d\TH:i:s\Z'); + $bill->setDueDate($dueDate); + + $subscription = new Subscription($bill); + + $this->assertEquals($bill, $subscription->getBillData()); + } + + public function testBillDataGetterSetter(): void + { + $subscription = new Subscription(); + + $items = []; + $item = new Item(); + $item->setPrice(10.0); + $item->setQuantity(1); + $items[] = $item; + + $bill = new Bill('testNumber', 'USD', 'test@example.com', $items); + $dueDate = (new \DateTime('now'))->format('Y-m-d\TH:i:s\Z'); + $bill->setDueDate($dueDate); + + $subscription->setBillData($bill); + + $this->assertEquals($bill, $subscription->getBillData()); + $this->assertEquals('testNumber', $subscription->getBillData()->getNumber()); + $this->assertEquals('USD', $subscription->getBillData()->getCurrency()); + } + + public function testStatusGetterSetter(): void + { + $subscription = new Subscription(); + $subscription->setStatus(SubscriptionStatus::ACTIVE); + + $this->assertEquals(SubscriptionStatus::ACTIVE, $subscription->getStatus()); + } + + public function testToArray(): void + { + $items = []; + $item = new Item(); + $item->setPrice(10.0); + $item->setQuantity(1); + $items[] = $item; + + $bill = new Bill('testNumber', 'USD', 'test@example.com', $items); + + $subscription = new Subscription($bill); + $subscription->setId('testId'); + $subscription->setStatus(SubscriptionStatus::ACTIVE); + + $result = $subscription->toArray(); + + $this->assertArrayHasKey('id', $result); + $this->assertArrayHasKey('status', $result); + $this->assertArrayHasKey('billData', $result); + $this->assertEquals('testId', $result['id']); + $this->assertEquals(SubscriptionStatus::ACTIVE, $result['status']); + } +}