diff --git a/.gitignore b/.gitignore index f2be0b4..bb48f6e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ .vscode/ phpunit.xml .env -docs/pr/ +docs/ +tmp/ +.codex diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a81267..9890469 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,4 +44,4 @@ composer cs-fix ## CI Pipeline -A GitHub Actions CI pipeline runs automatically on every push and pull request targeting `main`. It runs all checks across **PHP 8.2, 8.3, 8.4, and 8.5**. +A GitHub Actions CI pipeline runs automatically on every push and pull request targeting `main`. It runs all checks across **PHP 8.3, 8.4, and 8.5**. diff --git a/README.md b/README.md index aee24b2..ef5eab6 100644 --- a/README.md +++ b/README.md @@ -1,219 +1,565 @@ # Moyasar PHP - + A simple, expressive PHP client for the [Moyasar](https://moyasar.com) payment gateway. - -This package provides a clean interface for working with Moyasar's Invoices and Payments APIs in any PHP 8.2+ application. It's framework-agnostic and works great with Laravel, Symfony, or plain PHP. - + +This package provides a clean interface for working with Moyasar's Invoices and Payments APIs in any PHP 8.3+ application. It's framework-agnostic and works great with Laravel, Symfony, or plain PHP. + +## Table of Contents + +- [Requirements](#requirements) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Invoices](#invoices) + - [Create an Invoice](#create-an-invoice) + - [Bulk-Create Invoices](#bulk-create-invoices) + - [List Invoices](#list-invoices) + - [Get Invoice by ID](#get-invoice-by-id) + - [Update an Invoice](#update-an-invoice) + - [Cancel an Invoice](#cancel-an-invoice) + - [Invoice Response Objects](#invoice-response-objects) +- [Payments](#payments) + - [Create a Payment](#create-a-payment) + - [Fetch a Payment](#fetch-a-payment) + - [List Payments](#list-payments) + - [Update a Payment](#update-a-payment) + - [Refund a Payment](#refund-a-payment) + - [Capture an Authorized Payment](#capture-an-authorized-payment) + - [Void a Payment](#void-a-payment) + - [Payment Status Reference](#payment-status-reference) +- [Error Handling](#error-handling) +- [Testing Your Integration](#testing-your-integration) +- [Contributing](#contributing) +- [License](#license) +- [Next Steps](#next-steps) + ## Requirements - -- PHP **8.2+** + +- PHP **8.3+** - [Saloon](https://docs.saloon.dev) `^4.0` - [Saloon Pagination Plugin](https://docs.saloon.dev/docs/the-pagination-plugin) `^2.3` + ## Installation - + Install via Composer: - + ```bash composer require hamoda-dev/moyasar-php ``` - + That's it. No service providers to register, no config files to publish. The package is framework-agnostic — use it in Laravel, Symfony, Slim, or plain PHP. - + ## Quick Start - -Grab your secret key from the [Moyasar dashboard](https://dashboard.moyasar.com), then: - + +1. Grab your secret key from the [Moyasar dashboard](https://dashboard.moyasar.com) +2. Set the BaseUrl and your secret key in `.env` file + +```bash +MOYASAR_BASE_URL=https://api.moyasar.com/v1 +MOYASAR_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxx +``` + +3. Instantiate the moyasar client and create a quick test invoice + ```php use HamodaDev\Moyasar\Moyasar; -use HamodaDev\Moyasar\Invoice\DTO\CreateInvoiceDTO; - -$moyasar = new Moyasar( - baseUrl: 'https://api.moyasar.com/v1', - apiKey: getenv('MOYASAR_SECRET_KEY'), -); - +use HamodaDev\Moyasar\Invoice\Shared\DTOs\Requests\CreateInvoiceRequest; +use HamodaDev\Moyasar\Shared\Const\Currency; + +$moyasar = new Moyasar(getenv('MOYASAR_BASE_URL'), getenv('MOYASAR_SECRET_KEY')); + // Create an invoice and send the customer to the hosted payment page -$invoice = $moyasar->invoice()->create(new CreateInvoiceDTO( - amount: 2500, // 25.00 SAR — always in the smallest unit - currency: 'SAR', +$response = $moyasar->invoice()->create(new CreateInvoiceRequest( + amount: 25, + currency: Currency::SAR, description: 'Order #1234', callbackUrl: 'https://example.com/webhooks/moyasar', )); - -header("Location: {$invoice->url}"); ``` - -Three lines to take a payment. No Guzzle, no array-shuffling, no JSON decoding. - -### Recommended Environment Setup - -Keep credentials out of your code: - -```env -MOYASAR_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxx -MOYASAR_BASE_URL=https://api.moyasar.com/v1 -``` - + > [!WARNING] > **Never commit API keys to version control.** Use environment variables, a secrets manager, or your framework's config system. Treat your secret key like a password. - + > [!TIP] > Moyasar issues separate **test** (`sk_test_...`) and **live** (`sk_live_...`) keys. Use the test key in development and staging — you can run real payment flows against test cards without charging anyone. - + --- - + ## Invoices - -Invoices are the fastest way to accept a payment: you create one, send the customer to `invoice->url`, and Moyasar handles the entire checkout UI for you. - + +Invoices are the fastest way to accept a payment: you create one, send the customer to the URL from `getUrl()`, and Moyasar handles the entire checkout UI for you. + +All invoice methods return an `ApiResponse`. Call `getResponse()` to access the typed DTO when the request is successful, or `getResponseAsString()` when you need the raw JSON body. + +| Use case | Method | Returned DTO from `getResponse()` | +| --- | --- | --- | +| Create invoice | `$moyasar->invoice()->create($request)` | `InvoiceSuccessfulResponse\|null` | +| Bulk-create invoices | `$moyasar->invoice()->bulkCreate($request)` | `BulkInvoiceSuccessfulResponse\|null` | +| List invoices | `$moyasar->invoice()->list($request)` | `ListInvoiceSuccessfulResponse\|null` | +| Get invoice by ID | `$moyasar->invoice()->get($invoiceId)` | `InvoiceSuccessfulResponse\|null` | +| Update invoice metadata | `$moyasar->invoice()->update($request)` | `InvoiceSuccessfulResponse\|null` | +| Cancel invoice | `$moyasar->invoice()->cancel($invoiceId)` | `InvoiceSuccessfulResponse\|null` | + ### Create an Invoice - + +#### Sending Request + ```php -use HamodaDev\Moyasar\Invoice\DTO\CreateInvoiceDTO; - -$invoice = $moyasar->invoice()->create(new CreateInvoiceDTO( - amount: 2500, - currency: 'SAR', +use DateTimeImmutable; +use HamodaDev\Moyasar\Invoice\Shared\DTOs\Requests\CreateInvoiceRequest; +use HamodaDev\Moyasar\Shared\Const\Currency; + +$response = $moyasar->invoice()->create(new CreateInvoiceRequest( + amount: 25, + currency: Currency::SAR, description: 'Order #1234', callbackUrl: 'https://example.com/webhooks/moyasar', - successUrl: 'https://example.com/payment/success', - backUrl: 'https://example.com/payment/cancel', - metadata: ['order_id' => '1234'], + successUrl: 'https://example.com/webhooks/moyasar', + backUrl: 'https://example.com/webhooks/moyasar', + expiredAt: DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2026-06-04 05:21:17'), + metadata: [ 'client_id' => 'CID12345' ] )); - -echo $invoice->url; // Hosted payment page — redirect your user here -echo $invoice->id; // Store this alongside your order -echo $invoice->status; // "initiated" for a fresh invoice + +echo $response->isSuccessful(); // true if the http status code 200 <= code <= 299 +echo $response->getCode(); // 200 +echo $response->getResponseAsString(); // {...} +print_r($response->getResponse()); // returns an instance of InvoiceSuccessfulResponse if the response is successful, null otherwise ``` - -| Parameter | Type | Required | What it's for | -| --- | --- | --- | --- | -| `amount` | `int` | Yes | Smallest currency unit (halalas for SAR, cents for USD) | -| `currency` | `string` | Yes | ISO 4217 code (`SAR`, `USD`, ...) | -| `description` | `string` | Yes | Shown to the customer on the payment page | -| `callbackUrl` | `?string` | No | Webhook URL — Moyasar POSTs here when payment status changes | -| `successUrl` | `?string` | No | Where to redirect after a successful payment | -| `backUrl` | `?string` | No | Where to redirect if the customer cancels | -| `expiredAt` | `?string` | No | ISO 8601 — invoice auto-expires after this | -| `metadata` | `?array` | No | Arbitrary key-value data — perfect for your internal IDs | - -Prefer building DTOs from incoming request data? Use the array factory: - + +#### Request Object + +| Parameter | Type | Description | +| --- | --- | --- | +| `amount` | `float` | invoiced amount | +| `currency` | `Currency (enum)` | ISO 4217 code (`SAR`, `USD`, ...) | +| `description` | `string` | Shown to the customer on the payment page | +| `callbackUrl` | `string\|null` | Webhook URL — Moyasar POSTs here when payment status changes | +| `successUrl` | `string\|null` | Where to redirect after a successful payment | +| `backUrl` | `string\|null` | Where to redirect if the customer cancels | +| `expiredAt` | `DateTimeImmutable\|null` | ISO 8601 — invoice auto-expires after this | +| `metadata` | `array\|null` | Arbitrary key-value data — perfect for your internal IDs | + +You can also use the factory method to create the request DTO: + ```php -$dto = CreateInvoiceDTO::fromArray($request->validated()); +$dto = CreateInvoiceRequest::create( + amount: 25, + currency: Currency::SAR, + description: 'Order #1234', + // ... +); $invoice = $moyasar->invoice()->create($dto); ``` - -### Retrieve an Invoice - + +#### Returned Response Object + +| Method | Response wrapper | Successful response DTO | +| --- | --- | --- | +| `$moyasar->invoice()->create($request)` | `ApiResponse` | `InvoiceSuccessfulResponse` | + +| Getter | Type | Description | Example | +| --- | --- | --- | --- | +| `getId()` | `string` | Invoice ID, usually a UUID string | `e97e9999-a679-4cd7-95d0-69c9c886484e` | +| `getStatus()` | `InvoiceStatus` | Initial invoice status | `InvoiceStatus::INITIATED` | +| `getAmount()` | `int` | Amount in the currency minor unit | `2500` | +| `getAmountAsFloat()` | `float` | Amount converted to major currency units | `25.0` | +| `getCurrency()` | `Currency` | ISO 4217 code | `Currency::SAR` | +| `getDescription()` | `string` | Invoice description | `Order #1234` | +| `getAmountFormat()` | `string` | Human-readable amount | `25.00 SAR` | +| `getUrl()` | `string` | Hosted checkout URL | `https://checkout.moyasar.com/invoices/e97e9999-a679-4cd7-95d0-69c9c886484e?lang=en` | +| `getCreatedAt()` | `DateTimeImmutable` | Creation timestamp | `2026-05-13T16:43:54.509Z` | +| `getUpdatedAt()` | `DateTimeImmutable` | Last update timestamp | `2026-05-13T16:43:54.509Z` | +| `getLogoUrl()` | `string\|null` | Moyasar dashboard profile logo URL | `https://api.moyasar.com/images/default-logo.png` | +| `getMetadata()` | `array` | Metadata attached to the invoice | `['order_id' => '2222']` | +| `getPayments()` | `InvoicePayment[]` | Payment attempts linked to this invoice | `[]` | +| `getCallbackUrl()` | `string\|null` | Webhook URL | `https://example.com/webhooks/moyasar` | +| `getExpiredAt()` | `DateTimeImmutable\|null` | Expiration timestamp | `2038-01-19T03:14:07.000Z` | +| `getBackUrl()` | `string\|null` | Redirect URL when the customer goes back | `https://example.com/cart` | +| `getSuccessUrl()` | `string\|null` | Redirect URL after successful payment | `https://example.com/thanks` | + +Use `getUrl()` from the returned invoice to redirect the customer to Moyasar's hosted checkout. + ```php -$invoice = $moyasar->invoice()->get('invoice_12345'); - -if ($invoice->status === 'paid') { - // Fulfill the order +$apiResponse = $moyasar->invoice()->create($dto); +$invoice = $apiResponse->getResponse(); + +if ($apiResponse->isSuccessful() && $invoice !== null) { + echo $invoice->getUrl(); + echo $invoice->getAmountAsFloat(); } ``` - -### List Invoices (with Pagination) - -Moyasar returns invoices in pages. The SDK's paginator handles page-walking for you — no manual `?page=N` tracking: - + +### Bulk-Create Invoices + +#### Sending Request + ```php -$paginator = $moyasar->invoice()->list()->paginate($moyasar); - -while ($paginator->hasMorePages()) { - foreach ($paginator->items() as $invoice) { - echo "{$invoice->id} — {$invoice->status}\n"; - } - - $paginator = $paginator->nextPage(); +use HamodaDev\Moyasar\Invoice\Shared\DTOs\Requests\BulkCreateInvoiceRequest; +use HamodaDev\Moyasar\Invoice\Shared\DTOs\Requests\CreateInvoiceRequest; +use HamodaDev\Moyasar\Shared\Const\Currency; + +$response = $moyasar->invoice()->bulkCreate(new BulkCreateInvoiceRequest([ + new CreateInvoiceRequest(amount: 10, currency: Currency::SAR, description: 'Invoice A'), + new CreateInvoiceRequest(amount: 20, currency: Currency::SAR, description: 'Invoice B'), + new CreateInvoiceRequest(amount: 35, currency: Currency::SAR, description: 'Invoice C'), +])); + +foreach ($response->getResponse()?->getInvoices() ?? [] as $invoice) { + echo $invoice->getUrl(); } ``` - + +#### Request Object + +| Parameter | Type | Description | +| --- | --- | --- | +| `invoices` | `CreateInvoiceRequest[]` | Invoices to create in one API call | + +Each item uses the same fields documented in [Create an Invoice](#create-an-invoice). + +You can also use the factory method to validate the array contents: + +```php +$request = BulkCreateInvoiceRequest::create([ + CreateInvoiceRequest::create(10, Currency::SAR, 'Invoice A'), + CreateInvoiceRequest::create(20, Currency::SAR, 'Invoice B'), +]); +``` + +#### Returned Response Object + +| Method | Response wrapper | Successful response DTO | +| --- | --- | --- | +| `$moyasar->invoice()->bulkCreate($request)` | `ApiResponse` | `BulkInvoiceSuccessfulResponse` | + +| Getter | Type | Description | Example | +| --- | --- | --- | --- | +| `getInvoices()` | `InvoiceSuccessfulResponse[]` | Created invoices | `[$invoiceA, $invoiceB]` | + +### List Invoices + +#### Sending Request + +```php +use HamodaDev\Moyasar\Invoice\Internal\Core\Const\InvoiceStatus; +use HamodaDev\Moyasar\Invoice\Shared\DTOs\Requests\ListInvoiceRequest; + +$response = $moyasar->invoice()->list(new ListInvoiceRequest( + page: 1, + status: InvoiceStatus::PAID, + metadataKey: 'order_id', +)); + +$result = $response->getResponse(); + +foreach ($result?->getInvoices() ?? [] as $invoice) { + echo $invoice->getId(); +} + +$nextPage = $result?->getMeta()->getNextPage(); +``` + +#### Request Object + +| Parameter | Type | Description | +| --- | --- | --- | +| `page` | `int\|null` | Page number to retrieve. Defaults to `1` | +| `id` | `string\|null` | Filter by invoice ID | +| `status` | `InvoiceStatus\|null` | Filter by invoice status | +| `after` | `DateTimeImmutable\|null` | Return invoices created after this timestamp | +| `before` | `DateTimeImmutable\|null` | Return invoices created before this timestamp | +| `metadataKey` | `string\|null` | Filter invoices containing this metadata key | + +#### Returned Response Object + +| Method | Response wrapper | Successful response DTO | +| --- | --- | --- | +| `$moyasar->invoice()->list($request)` | `ApiResponse` | `ListInvoiceSuccessfulResponse` | + +| Getter | Type | Description | Example | +| --- | --- | --- | --- | +| `getInvoices()` | `InvoiceSuccessfulResponse[]` | Current page of invoices | `[$invoiceA, $invoiceB]` | +| `getMeta()` | `ResponsePaginator` | Pagination metadata | `$result->getMeta()->getCurrentPage() === 1` | + > [!TIP] -> Every item yielded by `items()` is a fully-typed `InvoiceDTO`. Your IDE will autocomplete `->id`, `->status`, `->amount`, and every other field. - +> Every invoice returned by `getInvoices()` is a fully-typed `InvoiceSuccessfulResponse`. Your IDE will autocomplete `getId()`, `getStatus()`, `getAmount()`, and every other getter. + +### Get Invoice by ID + +#### Sending Request + +```php +use HamodaDev\Moyasar\Invoice\Internal\Core\Const\InvoiceStatus; + +$response = $moyasar->invoice()->get('invoice_12345'); +$invoice = $response->getResponse(); + +if ($invoice?->getStatus() === InvoiceStatus::PAID) { + // Fulfill the order +} +``` + +#### Request Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| `invoiceId` | `string` | ID of the invoice to retrieve | + +#### Returned Response Object + +| Method | Response wrapper | Successful response DTO | +| --- | --- | --- | +| `$moyasar->invoice()->get($invoiceId)` | `ApiResponse` | `InvoiceSuccessfulResponse` | + +| Getter | Type | Description | Example | +| --- | --- | --- | --- | +| `getId()` | `string` | Invoice ID | `invoice_12345` | +| `getStatus()` | `InvoiceStatus` | Current invoice status | `InvoiceStatus::PAID` | +| `getAmount()` | `int` | Amount in the currency minor unit | `2500` | +| `getAmountAsFloat()` | `float` | Amount converted to major currency units | `25.0` | +| `getCurrency()` | `Currency` | ISO 4217 code | `Currency::SAR` | +| `getDescription()` | `string` | Invoice description | `Order #1234` | +| `getAmountFormat()` | `string` | Human-readable amount | `25.00 SAR` | +| `getUrl()` | `string` | Hosted checkout URL | `https://checkout.moyasar.com/invoices/invoice_12345?lang=en` | +| `getCreatedAt()` | `DateTimeImmutable` | Creation timestamp | `2026-05-13T16:43:54.509Z` | +| `getUpdatedAt()` | `DateTimeImmutable` | Last update timestamp | `2026-05-13T16:43:54.509Z` | +| `getLogoUrl()` | `string\|null` | Moyasar dashboard profile logo URL | `https://api.moyasar.com/images/default-logo.png` | +| `getMetadata()` | `array` | Metadata attached to the invoice | `['order_id' => '2222']` | +| `getPayments()` | `InvoicePayment[]` | Payment attempts linked to this invoice | `[$payment]` | +| `getCallbackUrl()` | `string\|null` | Webhook URL | `https://example.com/webhooks/moyasar` | +| `getExpiredAt()` | `DateTimeImmutable\|null` | Expiration timestamp | `2038-01-19T03:14:07.000Z` | +| `getBackUrl()` | `string\|null` | Redirect URL when the customer goes back | `https://example.com/cart` | +| `getSuccessUrl()` | `string\|null` | Redirect URL after successful payment | `https://example.com/thanks` | + +Use this response to check the latest invoice state, read payment attempts, or redirect to the hosted checkout URL again. + +```php +$invoice = $moyasar->invoice()->get('invoice_12345')->getResponse(); + +foreach ($invoice?->getPayments() ?? [] as $payment) { + echo $payment->getId(); + echo $payment->getStatus()->value; +} +``` + ### Update an Invoice - + +#### Sending Request + Only `metadata` is updatable after creation — use this to attach internal context as your order progresses: - + ```php -use HamodaDev\Moyasar\Invoice\DTO\UpdateInvoiceDTO; - -$moyasar->invoice()->update('invoice_12345', new UpdateInvoiceDTO( +use HamodaDev\Moyasar\Invoice\Shared\DTOs\Requests\UpdateInvoiceRequest; + +$response = $moyasar->invoice()->update(new UpdateInvoiceRequest( + invoiceId: 'invoice_12345', metadata: [ 'order_id' => '1234', - 'fulfilled_at' => now()->toIso8601String(), + 'fulfilled_at' => '2026-06-04T05:21:17.000Z', ], )); + +$invoice = $response->getResponse(); ``` - -### Bulk-Create Invoices - -Need to send 50 invoices for a batch of orders? One request, one round-trip: - + +#### Request Object + +| Parameter | Type | Description | +| --- | --- | --- | +| `invoiceId` | `string` | ID of the invoice to update | +| `metadata` | `array` | Metadata that should replace or update the invoice metadata | + +You can also use the named factory: + ```php -$result = $moyasar->invoice()->bulkCreate([ - new CreateInvoiceDTO(amount: 1000, currency: 'SAR', description: 'Invoice A'), - new CreateInvoiceDTO(amount: 2000, currency: 'SAR', description: 'Invoice B'), - new CreateInvoiceDTO(amount: 3500, currency: 'SAR', description: 'Invoice C'), +$request = UpdateInvoiceRequest::of('invoice_12345', [ + 'order_id' => '1234', ]); ``` - + +#### Returned Response Object + +| Method | Response wrapper | Successful response DTO | +| --- | --- | --- | +| `$moyasar->invoice()->update($request)` | `ApiResponse` | `InvoiceSuccessfulResponse` | + +| Getter | Type | Description | Example | +| --- | --- | --- | --- | +| `getId()` | `string` | Updated invoice ID | `invoice_12345` | +| `getStatus()` | `InvoiceStatus` | Current invoice status after update | `InvoiceStatus::INITIATED` | +| `getAmount()` | `int` | Amount in the currency minor unit | `2500` | +| `getAmountAsFloat()` | `float` | Amount converted to major currency units | `25.0` | +| `getCurrency()` | `Currency` | ISO 4217 code | `Currency::SAR` | +| `getDescription()` | `string` | Invoice description | `Order #1234` | +| `getAmountFormat()` | `string` | Human-readable amount | `25.00 SAR` | +| `getUrl()` | `string` | Hosted checkout URL | `https://checkout.moyasar.com/invoices/invoice_12345?lang=en` | +| `getCreatedAt()` | `DateTimeImmutable` | Creation timestamp | `2026-05-13T16:43:54.509Z` | +| `getUpdatedAt()` | `DateTimeImmutable` | Last update timestamp | `2026-05-13T16:50:12.000Z` | +| `getLogoUrl()` | `string\|null` | Moyasar dashboard profile logo URL | `https://api.moyasar.com/images/default-logo.png` | +| `getMetadata()` | `array` | Updated invoice metadata | `['order_id' => '1234']` | +| `getPayments()` | `InvoicePayment[]` | Payment attempts linked to this invoice | `[]` | +| `getCallbackUrl()` | `string\|null` | Webhook URL | `https://example.com/webhooks/moyasar` | +| `getExpiredAt()` | `DateTimeImmutable\|null` | Expiration timestamp | `2038-01-19T03:14:07.000Z` | +| `getBackUrl()` | `string\|null` | Redirect URL when the customer goes back | `https://example.com/cart` | +| `getSuccessUrl()` | `string\|null` | Redirect URL after successful payment | `https://example.com/thanks` | + +The returned invoice includes the updated metadata. + ### Cancel an Invoice - + +#### Sending Request + ```php -$invoice = $moyasar->invoice()->cancel('invoice_12345'); -// $invoice->status === 'canceled' +use HamodaDev\Moyasar\Invoice\Internal\Core\Const\InvoiceStatus; + +$response = $moyasar->invoice()->cancel('invoice_12345'); +$invoice = $response->getResponse(); + +// $invoice?->getStatus() === InvoiceStatus::CANCELED ``` - -### InvoiceDTO Reference - -| Property | Type | Notes | + +#### Request Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| `invoiceId` | `string` | ID of the invoice to cancel | + +#### Returned Response Object + +| Method | Response wrapper | Successful response DTO | | --- | --- | --- | -| `id` | `string` | Unique identifier | -| `status` | `string` | `initiated`, `pending`, `paid`, `expired`, `canceled` | -| `amount` | `int` | Smallest currency unit | -| `currency` | `string` | ISO 4217 | -| `description` | `string` | | -| `url` | `string` | **Hosted payment page — redirect customers here** | -| `amountFormat` | `string` | e.g. `"25.00 SAR"` | -| `logoUrl` | `string` | Your merchant logo | -| `callbackUrl` | `?string` | | -| `successUrl` | `?string` | | -| `backUrl` | `?string` | | -| `expiredAt` | `?string` | | -| `createdAt` | `string` | | -| `updatedAt` | `string` | | -| `metadata` | `array` | | -| `payments` | `array` | Payment attempts linked to this invoice | - -Need the raw Saloon response? It's always available: - +| `$moyasar->invoice()->cancel($invoiceId)` | `ApiResponse` | `InvoiceSuccessfulResponse` | + +| Getter | Type | Description | Example | +| --- | --- | --- | --- | +| `getId()` | `string` | Canceled invoice ID | `invoice_12345` | +| `getStatus()` | `InvoiceStatus` | Invoice status after cancellation | `InvoiceStatus::CANCELED` | +| `getAmount()` | `int` | Amount in the currency minor unit | `2500` | +| `getAmountAsFloat()` | `float` | Amount converted to major currency units | `25.0` | +| `getCurrency()` | `Currency` | ISO 4217 code | `Currency::SAR` | +| `getDescription()` | `string` | Invoice description | `Order #1234` | +| `getAmountFormat()` | `string` | Human-readable amount | `25.00 SAR` | +| `getUrl()` | `string` | Hosted checkout URL | `https://checkout.moyasar.com/invoices/invoice_12345?lang=en` | +| `getCreatedAt()` | `DateTimeImmutable` | Creation timestamp | `2026-05-13T16:43:54.509Z` | +| `getUpdatedAt()` | `DateTimeImmutable` | Last update timestamp | `2026-05-13T16:55:33.000Z` | +| `getLogoUrl()` | `string\|null` | Moyasar dashboard profile logo URL | `https://api.moyasar.com/images/default-logo.png` | +| `getMetadata()` | `array` | Metadata attached to the invoice | `['order_id' => '2222']` | +| `getPayments()` | `InvoicePayment[]` | Payment attempts linked to this invoice | `[]` | +| `getCallbackUrl()` | `string\|null` | Webhook URL | `https://example.com/webhooks/moyasar` | +| `getExpiredAt()` | `DateTimeImmutable\|null` | Expiration timestamp | `2038-01-19T03:14:07.000Z` | +| `getBackUrl()` | `string\|null` | Redirect URL when the customer goes back | `https://example.com/cart` | +| `getSuccessUrl()` | `string\|null` | Redirect URL after successful payment | `https://example.com/thanks` | + +The returned invoice should have `InvoiceStatus::CANCELED`. + +### Invoice Response Objects + +#### InvoiceSuccessfulResponse + +Returned by create, get by ID, update, and cancel invoice calls. It is also used for every invoice inside list and bulk-create responses. + +| Getter | Type | Description | Example | +| --- | --- | --- | --- | +| `getId()` | `string` | Invoice ID, usually a UUID string | `e97e9999-a679-4cd7-95d0-69c9c886484e` | +| `getStatus()` | `InvoiceStatus` | One of `INITIATED`, `PAID`, `FAILED`, `REFUNDED`, `CANCELED`, `ON_HOLD`, `EXPIRED`, `VOIDED` | `InvoiceStatus::CANCELED` | +| `getAmount()` | `int` | Amount in the currency minor unit | `2500` | +| `getAmountAsFloat()` | `float` | Amount converted to major currency units | `25.0` | +| `getCurrency()` | `Currency` | ISO 4217 code | `Currency::SAR` | +| `getDescription()` | `string` | Invoice description | `Order #1234` | +| `getAmountFormat()` | `string` | Human-readable amount | `25.00 SAR` | +| `getUrl()` | `string` | Hosted checkout URL | `https://checkout.moyasar.com/invoices/e97e9999-a679-4cd7-95d0-69c9c886484e?lang=en` | +| `getCreatedAt()` | `DateTimeImmutable` | Creation timestamp | `2026-05-13T16:43:54.509Z` | +| `getUpdatedAt()` | `DateTimeImmutable` | Last update timestamp | `2026-05-13T16:43:54.509Z` | +| `getLogoUrl()` | `string\|null` | Moyasar dashboard profile logo URL | `https://api.moyasar.com/images/default-logo.png` | +| `getMetadata()` | `array` | Metadata attached to the invoice | `['order_id' => '2222']` | +| `getPayments()` | `InvoicePayment[]` | Payment attempts linked to this invoice | `[]` | +| `getCallbackUrl()` | `string\|null` | Webhook URL | `https://example.com/webhooks/moyasar` | +| `getExpiredAt()` | `DateTimeImmutable\|null` | Expiration timestamp | `2038-01-19T03:14:07.000Z` | +| `getBackUrl()` | `string\|null` | Redirect URL when the customer goes back | `https://example.com/cart` | +| `getSuccessUrl()` | `string\|null` | Redirect URL after successful payment | `https://example.com/thanks` | + +#### BulkInvoiceSuccessfulResponse + +| Getter | Type | Description | Example | +| --- | --- | --- | --- | +| `getInvoices()` | `InvoiceSuccessfulResponse[]` | Created invoices | `[$invoiceA, $invoiceB]` | + +#### ListInvoiceSuccessfulResponse + +| Getter | Type | Description | Example | +| --- | --- | --- | --- | +| `getInvoices()` | `InvoiceSuccessfulResponse[]` | Current page of invoices | `[$invoiceA, $invoiceB]` | +| `getMeta()` | `ResponsePaginator` | Pagination metadata | `$result->getMeta()->getCurrentPage() === 1` | + +#### ResponsePaginator + +| Getter | Type | Description | Example | +| --- | --- | --- | --- | +| `getCurrentPage()` | `int` | Current page number | `1` | +| `getTotalPages()` | `int` | Total number of pages | `5` | +| `getTotalCount()` | `int` | Total number of invoices matching the query | `93` | +| `getNextPage()` | `int\|null` | Next page number, if one exists | `2` | +| `getPrevPage()` | `int\|null` | Previous page number, if one exists | `null` | + +#### InvoicePayment + +`InvoiceSuccessfulResponse::getPayments()` returns any payment attempts already linked to the invoice. + +| Getter | Type | Description | Example | +| --- | --- | --- | --- | +| `getId()` | `string` | Payment ID | `19f68d63-8f2b-4a7f-8f2c-8f1a6a4c8a11` | +| `getStatus()` | `PaymentStatus` | Payment status enum | `PaymentStatus::PAID` | +| `getAmount()` | `int` | Amount in the currency minor unit | `2500` | +| `getAmountAsFloat()` | `float` | Amount converted to major currency units | `25.0` | +| `getFee()` | `int` | Moyasar fee in the currency minor unit | `100` | +| `getFeeAsFloat()` | `float` | Fee converted to major currency units | `1.0` | +| `getCurrency()` | `Currency` | ISO 4217 code | `Currency::SAR` | +| `getRefunded()` | `int` | Refunded amount in the currency minor unit | `0` | +| `getRefundedAsFloat()` | `float` | Refunded amount converted to major currency units | `0.0` | +| `getCaptured()` | `int` | Captured amount in the currency minor unit | `2500` | +| `getCapturedAsFloat()` | `float` | Captured amount converted to major currency units | `25.0` | +| `getAmountFormat()` | `string` | Human-readable amount | `25.00 SAR` | +| `getFeeFormat()` | `string` | Human-readable fee | `1.00 SAR` | +| `getRefundedFormat()` | `string` | Human-readable refunded amount | `0.00 SAR` | +| `getCapturedFormat()` | `string` | Human-readable captured amount | `25.00 SAR` | +| `getIp()` | `string` | Customer IP address | `127.0.0.1` | +| `getSource()` | `InvoicePaymentSource` | Payment source details | Credit card, Apple Pay, STC Pay, or Samsung Pay details | +| `getCreatedAt()` | `DateTimeImmutable` | Creation timestamp | `2026-05-13T16:43:54.509Z` | +| `getUpdatedAt()` | `DateTimeImmutable` | Last update timestamp | `2026-05-13T16:43:54.509Z` | +| `getRefundedAt()` | `DateTimeImmutable\|null` | Refund timestamp, when refunded | `null` | +| `getCapturedAt()` | `DateTimeImmutable\|null` | Capture timestamp, when captured | `2026-05-13T16:45:10.000Z` | +| `getVoidedAt()` | `DateTimeImmutable\|null` | Void timestamp, when voided | `null` | +| `getDescription()` | `string\|null` | Payment description | `Order #1234` | +| `getInvoiceId()` | `string\|null` | Linked invoice ID | `e97e9999-a679-4cd7-95d0-69c9c886484e` | +| `getCallbackUrl()` | `string\|null` | Webhook URL | `https://example.com/webhooks/moyasar` | +| `getMetadata()` | `array\|null` | Payment metadata | `['order_id' => '2222']` | +| `getSplits()` | `InvoicePaymentSplit[]\|null` | Split payment recipients, if any | `[]` | + +Need the raw response body? It's always available from the `ApiResponse` wrapper: + ```php -$invoice = $moyasar->invoice()->get('invoice_12345'); -$response = $invoice->getResponse(); - -$response->status(); // 200 -$response->headers(); // All response headers -$response->body(); // Raw JSON string +$response = $moyasar->invoice()->get('invoice_12345'); + +$response->getCode(); // 200 +$response->isSuccessful(); // true +$response->getResponseAsString(); // Raw JSON string +$response->getResponse(); // InvoiceSuccessfulResponse|null ``` - + --- - + ## Payments - + Invoices are great when you want Moyasar to host the checkout. **Payments** are for when you want full control — your own card form, your own UX, direct charges against a card or token. - + > [!NOTE] > If you're collecting card details directly, make sure your integration is PCI-compliant. For most merchants, **tokenization** (using a saved `token` source) is safer and simpler than passing raw card numbers. - + ### Create a Payment - + ```php use HamodaDev\Moyasar\Payment\DTO\CreatePaymentDTO; use HamodaDev\Moyasar\Payment\DTO\Source\CreditCardSourceDTO; - + $payment = $moyasar->payment()->create(new CreatePaymentDTO( amount: 10000, // 100.00 SAR currency: 'SAR', @@ -234,57 +580,57 @@ $payment = $moyasar->payment()->create(new CreatePaymentDTO( givenId: 'a1168bd1-47a4-4b97-8a50-dd5caaccacf2', applyCoupon: true, )); - + echo $payment->status; // "initiated", "paid", "authorized", ... echo $payment->id; // Store this with your order ``` - + **About 3D Secure:** when `threeDs: true`, the response may include a redirect URL the customer must visit to complete verification. Always check `$payment->status` and any redirect instructions returned by the API before assuming the payment succeeded. - + **About manual capture:** setting `manual: true` authorizes the charge without capturing funds. Use this when you want to verify a payment now but only capture later (e.g. when you ship the item). See [Capture](#capture-an-authorized-payment) below. - + #### Supported Source Types - -| Source | DTO | Use case | -| --- | --- | --- | -| Credit/debit card | `CreditCardSourceDTO` | Direct card charge | -| Apple Pay | Pass a raw `array` as `source` | Apple Pay token from the browser/app | -| STC Pay | Pass a raw `array` as `source` | STC Pay flow | -| Saved token | `CreditCardSourceDTO` with `token` set | Charging a previously saved card | - + +| Source | DTO | Use case | +| ----------------- | -------------------------------------- | ------------------------------------ | +| Credit/debit card | `CreditCardSourceDTO` | Direct card charge | +| Apple Pay | Pass a raw `array` as `source` | Apple Pay token from the browser/app | +| STC Pay | Pass a raw `array` as `source` | STC Pay flow | +| Saved token | `CreditCardSourceDTO` with `token` set | Charging a previously saved card | + ### Fetch a Payment - + ```php $payment = $moyasar->payment()->get('payment_12345'); - + if ($payment->status === 'paid') { // Mark the order as paid } ``` - + ### List Payments - + Same paginator API as invoices: - + ```php $paginator = $moyasar->payment()->list()->paginate($moyasar); - + while ($paginator->hasMorePages()) { foreach ($paginator->items() as $payment) { echo "{$payment->id} — {$payment->status} — {$payment->amountFormat}\n"; } - + $paginator = $paginator->nextPage(); } ``` - + ### Update a Payment - + Update `description` or `metadata` after the fact — handy for enriching records once your internal workflow catches up: - + ```php use HamodaDev\Moyasar\Payment\DTO\UpdatePaymentDTO; - + $moyasar->payment()->update('payment_12345', new UpdatePaymentDTO( description: 'Kindle Paperwhite — refurbished', metadata: [ @@ -293,136 +639,136 @@ $moyasar->payment()->update('payment_12345', new UpdatePaymentDTO( ], )); ``` - + ### Refund a Payment - + Full refund: - + ```php $payment = $moyasar->payment()->refund('payment_12345'); ``` - + Partial refund (amount in smallest currency unit): - + ```php $payment = $moyasar->payment()->refund('payment_12345', amount: 2500); // Refund 25.00 SAR ``` - + ### Capture an Authorized Payment - + If you created the payment with `manual: true`, capture it when you're ready to actually charge the customer: - + ```php // Full capture $payment = $moyasar->payment()->capture('payment_12345'); - + // Partial capture (e.g. only ship part of an order) $payment = $moyasar->payment()->capture('payment_12345', amount: 5000); ``` - + ### Void a Payment - + Cancel a payment **before** the funds settle in your bank account. Works on `paid`, `authorized`, or `captured` payments — as long as settlement hasn't happened yet. - + ```php $payment = $moyasar->payment()->void('payment_12345'); // $payment->status === 'voided' ``` - + > [!TIP] -> **Void vs. refund:** void *prevents* the money from leaving the customer's account; refund *returns* money that's already moved. Void is cheaper and faster — always prefer it when available. - +> **Void vs. refund:** void _prevents_ the money from leaving the customer's account; refund _returns_ money that's already moved. Void is cheaper and faster — always prefer it when available. + ### Payment Status Reference - -| Status | What it means | -| --- | --- | -| `initiated` | Payment created, customer hasn't paid yet | -| `paid` | Payment succeeded — you can fulfill the order | -| `failed` | Payment failed — check `message` on the DTO for the reason | -| `authorized` | Card authorized but not charged — needs `capture()` | -| `captured` | Authorized payment has been successfully captured | -| `refunded` | Payment refunded (full or partial) | -| `voided` | Payment canceled before settlement | -| `verified` | Card verified during tokenization (no charge made) | - + +| Status | What it means | +| ------------ | ---------------------------------------------------------- | +| `initiated` | Payment created, customer hasn't paid yet | +| `paid` | Payment succeeded — you can fulfill the order | +| `failed` | Payment failed — check `message` on the DTO for the reason | +| `authorized` | Card authorized but not charged — needs `capture()` | +| `captured` | Authorized payment has been successfully captured | +| `refunded` | Payment refunded (full or partial) | +| `voided` | Payment canceled before settlement | +| `verified` | Card verified during tokenization (no charge made) | + --- - + ## Error Handling - + Saloon throws `RequestException` on any non-2xx response. The response object is attached, so you get full context: - + ```php use Saloon\Exceptions\Request\RequestException; use Saloon\Exceptions\Request\FatalRequestException; - + try { $payment = $moyasar->payment()->get('invalid_id'); } catch (RequestException $e) { $status = $e->getResponse()->status(); $body = $e->getResponse()->json(); - + // Moyasar returns structured errors $type = $body['type'] ?? null; // e.g. "invalid_request_error" $message = $body['message'] ?? null; // Human-readable summary $errors = $body['errors'] ?? []; // Field-level validation errors - + report($e); } catch (FatalRequestException $e) { // Network failure — didn't even reach Moyasar report($e); } ``` - + ### Moyasar Error Types - -| Type | Meaning | What to do | -| --- | --- | --- | -| `invalid_request_error` | You sent bad parameters | Check the `errors` field, fix, retry | -| `authentication_error` | Invalid API key | Verify your secret key and base URL | -| `rate_limit_error` | Too many requests | Back off and retry with exponential delay | -| `api_connection_error` | Couldn't reach Moyasar | Retry with backoff | -| `account_inactive_error` | Account not activated for live payments | Contact Moyasar sales | -| `3ds_auth_error` | 3D Secure failed | Ask the customer to try again | -| `api_error` | Something else went wrong | Retry; contact support if it persists | - + +| Type | Meaning | What to do | +| ------------------------ | --------------------------------------- | ----------------------------------------- | +| `invalid_request_error` | You sent bad parameters | Check the `errors` field, fix, retry | +| `authentication_error` | Invalid API key | Verify your secret key and base URL | +| `rate_limit_error` | Too many requests | Back off and retry with exponential delay | +| `api_connection_error` | Couldn't reach Moyasar | Retry with backoff | +| `account_inactive_error` | Account not activated for live payments | Contact Moyasar sales | +| `3ds_auth_error` | 3D Secure failed | Ask the customer to try again | +| `api_error` | Something else went wrong | Retry; contact support if it persists | + ### HTTP Status Codes - -| Code | Meaning | -| --- | --- | -| `200` | Success | -| `400` | Bad request — missing or invalid parameters | -| `401` | Unauthorized — API key invalid | -| `403` | Forbidden — credentials lack permission | -| `404` | Resource not found | -| `405` | Method not allowed — account not activated for live | -| `429` | Rate limited | -| `500` / `503` | Moyasar server issue — retry later | - + +| Code | Meaning | +| ------------- | --------------------------------------------------- | +| `200` | Success | +| `400` | Bad request — missing or invalid parameters | +| `401` | Unauthorized — API key invalid | +| `403` | Forbidden — credentials lack permission | +| `404` | Resource not found | +| `405` | Method not allowed — account not activated for live | +| `429` | Rate limited | +| `500` / `503` | Moyasar server issue — retry later | + > [!WARNING] -> Moyasar occasionally returns **`201` with a failure payload** (e.g. bank declines). Don't trust the status code alone — always inspect `$payment->status` or `$invoice->status` on the DTO. - +> Moyasar occasionally returns **`201` with a failure payload** (e.g. bank declines). Don't trust the status code alone — always inspect the payment status or `$invoice->getStatus()` on the DTO. + --- - + ## Testing Your Integration - + Use Moyasar's [test cards](https://docs.moyasar.com/testing) with your `sk_test_...` key. A few quick patterns: - -| Scenario | Card number | -| --- | --- | -| Successful charge | `4111 1111 1111 1111` | -| Declined charge | `4000 0000 0000 0002` | + +| Scenario | Card number | +| ------------------ | --------------------- | +| Successful charge | `4111 1111 1111 1111` | +| Declined charge | `4000 0000 0000 0002` | | 3D Secure required | `4000 0000 0000 3220` | - + Always verify your webhook handler works end-to-end in `test` mode before flipping to live. - + --- - + ## Contributing - + Bug reports and pull requests welcome. If you're adding a new Moyasar endpoint, please follow the existing Resource / Request / DTO pattern — consistency is why this SDK is pleasant to use. - + ## License - + Moyasar PHP is open-sourced software licensed under the [MIT license](LICENSE). ## Next Steps diff --git a/TODO.md b/TODO.md index 26911b0..191c504 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,3 @@ # TODOs -1. Add Tests -2. Enhance the design (no magic constants, better type casting) -3. Setup CI/CD pipeline -4. Decouple the application from the infrastructure (Saloon) +1. Make Moyasar a singleton class diff --git a/composer.lock b/composer.lock index c581aa1..a75a3b6 100644 --- a/composer.lock +++ b/composer.lock @@ -672,16 +672,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -694,7 +694,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -719,7 +719,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -730,12 +730,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-04-13T15:52:40+00:00" } ], "packages-dev": [ @@ -4236,20 +4240,19 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.37.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", "shasum": "" }, "require": { - "ext-iconv": "*", "php": ">=7.2" }, "provide": { @@ -4297,7 +4300,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" }, "funding": [ { @@ -4308,16 +4311,12 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2026-04-10T17:25:58+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/process", @@ -4386,16 +4385,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "shasum": "" }, "require": { @@ -4413,7 +4412,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -4449,7 +4448,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" }, "funding": [ { @@ -4469,7 +4468,7 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-03-28T09:44:51+00:00" }, { "name": "symfony/string", @@ -4744,5 +4743,5 @@ "platform-overrides": { "php": "8.3.0" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/src/Invoice/APIs/BulkCreateInvoicesRequest.php b/src/Invoice/APIs/BulkCreateInvoicesRequest.php deleted file mode 100644 index 60e6b44..0000000 --- a/src/Invoice/APIs/BulkCreateInvoicesRequest.php +++ /dev/null @@ -1,64 +0,0 @@ - $invoices - */ - public function __construct( - public readonly array $invoices, - ) { - } - - public function resolveEndpoint(): string - { - return '/invoices/bulk'; - } - - public function defaultBody(): array - { - return [ - 'invoices' => array_map( - fn(CreateInvoiceDTO $dto): array => array_filter([ - 'amount' => $dto->amount, - 'currency' => $dto->currency, - 'description' => $dto->description, - 'callback_url' => $dto->callbackUrl, - 'success_url' => $dto->successUrl, - 'back_url' => $dto->backUrl, - 'expired_at' => $dto->expiredAt, - 'metadata' => $dto->metadata, - ], fn(mixed $value): bool => $value !== null), - $this->invoices, - ), - ]; - } - - /** - * @param Response $response - * @return array{invoices: InvoiceDTO[]} - */ - public function createDtoFromResponse(Response $response): array - { - $data = []; - $data['invoices'] = array_map( - fn(array $invoice): InvoiceDTO => InvoiceDTO::fromArray($invoice), - $response->json('invoices', []) - ); - return $data; - } -} diff --git a/src/Invoice/APIs/CancelInvoiceRequest.php b/src/Invoice/APIs/CancelInvoiceRequest.php deleted file mode 100644 index 12ae829..0000000 --- a/src/Invoice/APIs/CancelInvoiceRequest.php +++ /dev/null @@ -1,28 +0,0 @@ -invoiceId}/cancel"; - } - - public function createDtoFromResponse(Response $response): InvoiceDTO - { - return InvoiceDTO::fromResponse($response); - } -} diff --git a/src/Invoice/APIs/CreateInvoiceRequest.php b/src/Invoice/APIs/CreateInvoiceRequest.php deleted file mode 100644 index a475022..0000000 --- a/src/Invoice/APIs/CreateInvoiceRequest.php +++ /dev/null @@ -1,47 +0,0 @@ - $this->createInvoiceDTO->amount, - 'currency' => $this->createInvoiceDTO->currency, - 'description' => $this->createInvoiceDTO->description, - 'callback_url' => $this->createInvoiceDTO->callbackUrl, - 'success_url' => $this->createInvoiceDTO->successUrl, - 'back_url' => $this->createInvoiceDTO->backUrl, - 'expired_at' => $this->createInvoiceDTO->expiredAt, - 'metadata' => $this->createInvoiceDTO->metadata, - ], fn (mixed $value): bool => $value !== null); - } - - public function createDtoFromResponse(Response $response): InvoiceDTO - { - return InvoiceDTO::fromResponse($response); - } -} diff --git a/src/Invoice/APIs/GetInvoiceRequest.php b/src/Invoice/APIs/GetInvoiceRequest.php deleted file mode 100644 index 5fe4fbf..0000000 --- a/src/Invoice/APIs/GetInvoiceRequest.php +++ /dev/null @@ -1,28 +0,0 @@ -invoiceId}"; - } - - public function createDtoFromResponse(Response $response): InvoiceDTO - { - return InvoiceDTO::fromResponse($response); - } -} diff --git a/src/Invoice/APIs/ListInvoicesRequest.php b/src/Invoice/APIs/ListInvoicesRequest.php deleted file mode 100644 index b96434f..0000000 --- a/src/Invoice/APIs/ListInvoicesRequest.php +++ /dev/null @@ -1,26 +0,0 @@ -json('meta.next_page') === null; - } - - protected function getPageItems(Response $response, Request $request): array - { - $invoices = $response->json('invoices', []); - - return array_map( - fn(array $invoice): InvoiceDTO => InvoiceDTO::fromArray($invoice), - $invoices, - ); - } -} diff --git a/src/Invoice/APIs/UpdateInvoiceRequest.php b/src/Invoice/APIs/UpdateInvoiceRequest.php deleted file mode 100644 index d06f8a6..0000000 --- a/src/Invoice/APIs/UpdateInvoiceRequest.php +++ /dev/null @@ -1,41 +0,0 @@ -invoiceId}"; - } - - public function defaultBody(): array - { - return [ - 'metadata' => $this->updateInvoiceDTO->metadata, - ]; - } - - public function createDtoFromResponse(Response $response): InvoiceDTO - { - return InvoiceDTO::fromResponse($response); - } -} diff --git a/src/Invoice/DTO/CreateInvoiceDTO.php b/src/Invoice/DTO/CreateInvoiceDTO.php deleted file mode 100644 index 4555f00..0000000 --- a/src/Invoice/DTO/CreateInvoiceDTO.php +++ /dev/null @@ -1,32 +0,0 @@ - $metadata - * @param array $payments - * Note: $logoUrl is not a part of the ListInvoice response, so, it's made nullable - */ - public function __construct( - public string $id, - public string $status, - public int $amount, - public string $currency, - public string $description, - public ?string $logoUrl, - public string $amountFormat, - public string $url, - public ?string $callbackUrl = null, - public ?string $expiredAt = null, - public string $createdAt = '', - public string $updatedAt = '', - public ?string $backUrl = null, - public ?string $successUrl = null, - public array $metadata = [], - public array $payments = [], - ) { - } - - public static function fromResponse(Response $response): self - { - return self::fromArray($response->json()); - } - - /** - * @param array $data - */ - public static function fromArray(array $data): self - { - return new self( - id: $data['id'], - status: $data['status'], - amount: (int) $data['amount'], - currency: $data['currency'], - description: $data['description'], - logoUrl: $data['logo_url'] ?? null, - amountFormat: $data['amount_format'], - url: $data['url'], - callbackUrl: $data['callback_url'] ?? null, - expiredAt: $data['expired_at'] ?? null, - createdAt: $data['created_at'] ?? '', - updatedAt: $data['updated_at'] ?? '', - backUrl: $data['back_url'] ?? null, - successUrl: $data['success_url'] ?? null, - metadata: $data['metadata'] ?? [], - payments: $data['payments'] ?? [], - ); - } -} diff --git a/src/Invoice/DTO/UpdateInvoiceDTO.php b/src/Invoice/DTO/UpdateInvoiceDTO.php deleted file mode 100644 index 1c161ba..0000000 --- a/src/Invoice/DTO/UpdateInvoiceDTO.php +++ /dev/null @@ -1,14 +0,0 @@ - $metadata - */ - public function __construct( - public array $metadata, - ) { - } -} diff --git a/src/Invoice/Internal/Cancel/CancelInvoiceSaloonRequest.php b/src/Invoice/Internal/Cancel/CancelInvoiceSaloonRequest.php new file mode 100644 index 0000000..0e68ac8 --- /dev/null +++ b/src/Invoice/Internal/Cancel/CancelInvoiceSaloonRequest.php @@ -0,0 +1,52 @@ +invoiceId}/cancel"; + } + + /** + * Summary of createDtoFromResponse + * @param Response $response + * @return ApiResponse + */ + #[Override] + public function createDtoFromResponse(Response $response): ApiResponse + { + $code = $response->status(); + $responseArr = $response->json(); + $responseStr = $response->body(); + $responseObject = null; + + if ($response->successful()) { + InvoiceResponseValidator::for($responseArr, 'response')->validate(); + $responseObject = InvoiceSuccessfulResponse::fromArray($responseArr); + } + + return new ApiResponse( + code: $code, + response: $responseObject, + responseAsString: $responseStr + ); + } +} diff --git a/src/Invoice/Internal/Core/Const/InvoiceStatus.php b/src/Invoice/Internal/Core/Const/InvoiceStatus.php new file mode 100644 index 0000000..dfd0909 --- /dev/null +++ b/src/Invoice/Internal/Core/Const/InvoiceStatus.php @@ -0,0 +1,15 @@ + + */ + protected array $requiredEnums = []; + + /** + * An associative array contains the property name as key for optional enum value casts, + * and the Enum class string as value + * @var array + */ + protected array $optionalEnums = []; + + /** + * Required timestamps' props names + * @var string[] + */ + protected array $requiredTimestamps = []; + + /** + * Optional timestamps' props names + * @var string[] + */ + protected array $optionalTimestamps = []; + + public function __construct( + protected readonly array $subject, + /** + * @var string the name of the subject context, used for error messages + * @example response + * @example response.property[0] + */ + protected readonly string $context = 'response' + ) { + } + + public static function for(array $subject, string $context = 'response'): self + { + return new self($subject, $context); + } + + /** + * @throws InvalidArgumentException on the first first violated validation rule + * @return void + */ + public function validate(): void + { + $this->validateMandatoryFields(); + $this->validateEnums(); + $this->validateTimeStamps(); + $this->runAdditionalValidations(); + } + + protected function validateMandatoryFields(): void + { + foreach ($this->requiredFields as $k) { + if (!\array_key_exists($k, $this->subject)) { + throw new BadMethodCallException("Cannot cast {$this->context} array to object, missing key `{$k}`"); + } + } + } + + protected function validateEnums(): void + { + $this->validateMandatoryEnumFields(); + $this->validateOptionalEnumFields(); + } + + protected function validateTimestamps(): void + { + $this->validateMandatoryTimestamps(); + $this->validateOptionalTimestamps(); + } + + protected function validateMandatoryEnumFields(): void + { + foreach ($this->requiredEnums as $prop => $class) { + $this->castEnumField($prop, $class); + } + } + + protected function validateOptionalEnumFields(): void + { + foreach ($this->optionalEnums as $prop => $class) { + if (\array_key_exists($prop, $this->subject) && !empty($this->subject[$prop])) { + $this->castEnumField($prop, $class); + } + } + } + + /** + * Casts a subject prop to an enum instance + * @param string $prop + * @param string $class + * @throws InvalidArgumentException + * @return void + */ + protected function castEnumField(string $prop, string $class) + { + try { + $class::from($this->subject[$prop]); + } catch (TypeError | ValueError $e) { + $msg = "{$this->context}.{$prop}: {$this->subject[$prop]} cannot be casted to a valid {$class}," + . " supported statuses are: " + . json_encode($class::cases()); + throw new InvalidArgumentException($msg); + } + } + + protected function validateMandatoryTimestamps(): void + { + foreach ($this->requiredTimestamps as $field) { + $this->castTimestamp($field); + } + } + + protected function validateOptionalTimestamps(): void + { + foreach ($this->optionalTimestamps as $field) { + if (\array_key_exists($field, $this->subject) && !empty($this->subject[$field])) { + $this->castTimestamp($field); + } + } + } + + protected function castTimestamp(string $key) + { + $timestamp = $this->subject[$key]; + echo $key, $timestamp, "\n"; + + try { + new DateTimeImmutable($timestamp); + } catch (DateMalformedStringException $e) { + $msg = "{$this->context}.{$key}: {$timestamp} cannot be casted to a valid DateTimeImmutable"; + throw new InvalidArgumentException($msg); + } + } + + /** + * Run extra validations besides required fields, enum casts, timstamps' casts + * @throws InvalidArgumentException at the first violated validation rule + * @return void + */ + protected function runAdditionalValidations(): void + { + } +} diff --git a/src/Invoice/Internal/Core/Validators/InvoiceResponseValidator.php b/src/Invoice/Internal/Core/Validators/InvoiceResponseValidator.php new file mode 100644 index 0000000..e72bf67 --- /dev/null +++ b/src/Invoice/Internal/Core/Validators/InvoiceResponseValidator.php @@ -0,0 +1,44 @@ + InvoiceStatus::class, 'currency' => Currency::class]; + protected array $requiredTimestamps = ['created_at', 'updated_at']; + protected array $optionalTimestamps = ['expired_at']; + + #[Override] + protected function runAdditionalValidations(): void + { + if (!is_numeric($this->subject['amount'])) { + $msg = "{$this->context}.amount: {$this->subject['amount']} is not a number"; + throw new InvalidArgumentException($msg); + } + + if (\array_key_exists('payments', $this->subject)) { + foreach ($this->subject['payments'] as $i => $pay) { + InvoiceResponsePaymentValidator::for($pay, "{$this->context}.payments[{$i}]")->validate(); + } + } + } +} diff --git a/src/Invoice/Internal/Core/Validators/Pagination/PaginationMetadataValidator.php b/src/Invoice/Internal/Core/Validators/Pagination/PaginationMetadataValidator.php new file mode 100644 index 0000000..2dfc362 --- /dev/null +++ b/src/Invoice/Internal/Core/Validators/Pagination/PaginationMetadataValidator.php @@ -0,0 +1,23 @@ +requiredFields as $f) { + if (!is_numeric($this->subject[$f])) { + $msg = "{$this->context}.{$f}: {$this->subject[$f]} is not a number"; + throw new InvalidArgumentException($msg); + } + } + } +} diff --git a/src/Invoice/Internal/Core/Validators/Payment/InvoiceResponsePaymentValidator.php b/src/Invoice/Internal/Core/Validators/Payment/InvoiceResponsePaymentValidator.php new file mode 100644 index 0000000..7bf0348 --- /dev/null +++ b/src/Invoice/Internal/Core/Validators/Payment/InvoiceResponsePaymentValidator.php @@ -0,0 +1,72 @@ + InvoiceStatus::class, + 'currency' => Currency::class + ]; + + protected array $requiredTimestamps = ['created_at', 'updated_at']; + + protected array $optionalTimestamps = ['refunded_at', 'captured_at', 'voided_at']; + + #[Override] + protected function runAdditionalValidations(): void + { + $this->validatePrimitiveTypes(); + $this->validateComposites(); + } + + private function validatePrimitiveTypes(): void + { + $numerics = ['amount', 'fee', 'refunded', 'captured']; + + foreach ($numerics as $n) { + if (!is_numeric($this->subject[$n])) { + $msg = "{$this->context}.{$n}: {$this->subject[$n]} is not a number"; + throw new InvalidArgumentException($msg); + } + } + } + + private function validateComposites(): void + { + // 'source', 'splits' + InvoicePaymentSourceValidator::for($this->subject['source'])->validate(); + + if (\array_key_exists('splits', $this->subject)) { + foreach ($this->subject['splits'] as $i => $sp) { + InvoicePaymentSplitsValidator::for($sp, "{$this->context}.splits[{$i}]")->validate(); + } + } + } +} diff --git a/src/Invoice/Internal/Core/Validators/PaymentSource/InvoiceApplePayPaymentSourceValidator.php b/src/Invoice/Internal/Core/Validators/PaymentSource/InvoiceApplePayPaymentSourceValidator.php new file mode 100644 index 0000000..1d85262 --- /dev/null +++ b/src/Invoice/Internal/Core/Validators/PaymentSource/InvoiceApplePayPaymentSourceValidator.php @@ -0,0 +1,23 @@ + PaymentSourceCompany::class]; + protected array $optionalEnums = ['issuer_card_type' => PaymentCardType::class]; +} diff --git a/src/Invoice/Internal/Core/Validators/PaymentSource/InvoiceCreditCardPaymentSourceValidator.php b/src/Invoice/Internal/Core/Validators/PaymentSource/InvoiceCreditCardPaymentSourceValidator.php new file mode 100644 index 0000000..a371bb9 --- /dev/null +++ b/src/Invoice/Internal/Core/Validators/PaymentSource/InvoiceCreditCardPaymentSourceValidator.php @@ -0,0 +1,25 @@ + PaymentSourceCompany::class]; + protected array $optionalEnums = ['issuer_card_type' => PaymentCardType::class]; +} diff --git a/src/Invoice/Internal/Core/Validators/PaymentSource/InvoicePaymentSourceValidator.php b/src/Invoice/Internal/Core/Validators/PaymentSource/InvoicePaymentSourceValidator.php new file mode 100644 index 0000000..f4fd59e --- /dev/null +++ b/src/Invoice/Internal/Core/Validators/PaymentSource/InvoicePaymentSourceValidator.php @@ -0,0 +1,27 @@ + PaymentSourceType::class]; + + #[Override] + protected function runAdditionalValidations(): void + { + $newContext = "{$this->context}[={$this->subject['type']}]"; + + match ($this->subject['type']) { + 'creditcard' => InvoiceCreditCardPaymentSourceValidator::for($this->subject, $newContext)->validate(), + 'applepay' => InvoiceApplePayPaymentSourceValidator::for($this->subject, $newContext)->validate(), + 'samsungpay' => InvoiceSamsungPayPaymentSourceValidator::for($this->subject, $newContext)->validate(), + 'stcpay' => InvoiceStcPayPaymentSourceValidator::for($this->subject, $newContext)->validate(), + default => true, + }; + } +} diff --git a/src/Invoice/Internal/Core/Validators/PaymentSource/InvoiceSamsungPayPaymentSourceValidator.php b/src/Invoice/Internal/Core/Validators/PaymentSource/InvoiceSamsungPayPaymentSourceValidator.php new file mode 100644 index 0000000..665f6a1 --- /dev/null +++ b/src/Invoice/Internal/Core/Validators/PaymentSource/InvoiceSamsungPayPaymentSourceValidator.php @@ -0,0 +1,23 @@ + PaymentSourceCompany::class]; + protected array $optionalEnums = ['issuer_card_type' => PaymentCardType::class]; +} diff --git a/src/Invoice/Internal/Core/Validators/PaymentSource/InvoiceStcPayPaymentSourceValidator.php b/src/Invoice/Internal/Core/Validators/PaymentSource/InvoiceStcPayPaymentSourceValidator.php new file mode 100644 index 0000000..60134ff --- /dev/null +++ b/src/Invoice/Internal/Core/Validators/PaymentSource/InvoiceStcPayPaymentSourceValidator.php @@ -0,0 +1,10 @@ + SplitRecipientType::class]; +} diff --git a/src/Invoice/Internal/Create/BulkCreateInvoiceSaloonRequest.php b/src/Invoice/Internal/Create/BulkCreateInvoiceSaloonRequest.php new file mode 100644 index 0000000..3d006af --- /dev/null +++ b/src/Invoice/Internal/Create/BulkCreateInvoiceSaloonRequest.php @@ -0,0 +1,75 @@ +invoices; + } + + /** + * Summary of createDtoFromResponse + * @param Response $response + * @return ApiResponse + */ + public function createDtoFromResponse(Response $response): ApiResponse + { + $code = $response->status(); + $responseArr = $response->json(); + $responseStr = $response->body(); + + /** + * @var InvoiceSuccessfulResponse[] + */ + $singleInvoiceResponses = []; + + if ($response->successful() && \array_key_exists('invoices', $responseArr)) { + foreach ($responseArr['invoices'] as $key => $invResponse) { + InvoiceResponseValidator::for($invResponse, "response.invoices[{$key}]")->validate(); + $singleInvoiceResponses[] = InvoiceSuccessfulResponse::fromArray($invResponse); + } + } + + $responseObj = BulkInvoiceSuccessfulResponse::fromArrayOfObjects($singleInvoiceResponses); + + return new ApiResponse( + code: $code, + response: $responseObj, + responseAsString: $responseStr + ); + } +} diff --git a/src/Invoice/Internal/Create/CreateInvoiceSaloonRequest.php b/src/Invoice/Internal/Create/CreateInvoiceSaloonRequest.php new file mode 100644 index 0000000..b53b8b6 --- /dev/null +++ b/src/Invoice/Internal/Create/CreateInvoiceSaloonRequest.php @@ -0,0 +1,66 @@ +invoice, fn(mixed $value): bool => $value !== null); + } + + /** + * Summary of createDtoFromResponse + * @param Response $response + * @return ApiResponse + */ + public function createDtoFromResponse(Response $response): ApiResponse + { + $code = $response->status(); + $responseArr = $response->json(); + $responseStr = $response->body(); + $responseObject = null; + + if ($response->successful()) { + InvoiceResponseValidator::for($responseArr, 'response')->validate(); + $responseObject = InvoiceSuccessfulResponse::fromArray($responseArr); + } + + return new ApiResponse( + code: $code, + response: $responseObject, + responseAsString: $responseStr + ); + } +} diff --git a/src/Invoice/Internal/Get/GetInvoiceSaloonRequest.php b/src/Invoice/Internal/Get/GetInvoiceSaloonRequest.php new file mode 100644 index 0000000..088eb87 --- /dev/null +++ b/src/Invoice/Internal/Get/GetInvoiceSaloonRequest.php @@ -0,0 +1,49 @@ +invoiceId}"; + } + + /** + * Summary of createDtoFromResponse + * @param Response $response + * @return ApiResponse + */ + public function createDtoFromResponse(Response $response): ApiResponse + { + $code = $response->status(); + $responseArr = $response->json(); + $responseStr = $response->body(); + $responseObject = null; + + if ($response->successful()) { + InvoiceResponseValidator::for($responseArr, 'response')->validate(); + $responseObject = InvoiceSuccessfulResponse::fromArray($responseArr); + } + + return new ApiResponse( + code: $code, + response: $responseObject, + responseAsString: $responseStr + ); + } +} diff --git a/src/Invoice/Internal/Get/ListInvoiceSaloonRequest.php b/src/Invoice/Internal/Get/ListInvoiceSaloonRequest.php new file mode 100644 index 0000000..a137935 --- /dev/null +++ b/src/Invoice/Internal/Get/ListInvoiceSaloonRequest.php @@ -0,0 +1,76 @@ +params->toRequestArray(); + } + + /** + * @return ApiResponse + */ + #[Override] + public function createDtoFromResponse(Response $response): ApiResponse + { + $code = $response->status(); + $responseArr = $response->json(); + $responseStr = $response->body(); + + /** + * @var InvoiceSuccessfulResponse[] + */ + $singleInvoiceResponses = []; + $metaResponse = []; + + if ( + $response->successful() + && \array_key_exists('invoices', $responseArr) + && \array_key_exists('meta', $responseArr) + ) { + foreach ($responseArr['invoices'] as $key => $invResponse) { + InvoiceResponseValidator::for($invResponse, "response.invoices[{$key}]")->validate(); + $singleInvoiceResponses[] = InvoiceSuccessfulResponse::fromArray($invResponse); + } + + PaginationMetadataValidator::for($responseArr['meta'], 'response.meta')->validate(); + $metaResponse = ResponsePaginator::fromArray($responseArr['meta']); + } + + $responseObj = ListInvoiceSuccessfulResponse::fromArrayOfObjects($singleInvoiceResponses, $metaResponse); + + return new ApiResponse( + code: $code, + response: $responseObj, + responseAsString: $responseStr + ); + } +} diff --git a/src/Invoice/Internal/InvoiceResource.php b/src/Invoice/Internal/InvoiceResource.php new file mode 100644 index 0000000..dc6678b --- /dev/null +++ b/src/Invoice/Internal/InvoiceResource.php @@ -0,0 +1,75 @@ + + */ + public function get(string $invoiceId): ApiResponse + { + return $this->connector->send(new GetInvoiceSaloonRequest($invoiceId))->dto(); + } + + /** + * @param ListInvoiceRequest $request + * @return ApiResponse<\HamodaDev\Moyasar\Invoice\Shared\DTOs\Responses\ListInvoiceSuccessfulResponse> + */ + public function list(ListInvoiceRequest $request): ApiResponse + { + return $this->connector->send(new ListInvoiceSaloonRequest($request))->dto(); + } + + /** + * @param CreateInvoiceRequest $invoice + * @return ApiResponse<\HamodaDev\Moyasar\Invoice\Shared\DTOs\Responses\InvoiceSuccessfulResponse> + */ + public function create(CreateInvoiceRequest $invoice): ApiResponse + { + return $this->connector->send(new CreateInvoiceSaloonRequest($invoice->toRequestArray()))->dto(); + } + + /** + * @param BulkCreateInvoiceRequest $invoices + * @return ApiResponse<\HamodaDev\Moyasar\Invoice\Shared\DTOs\Responses\BulkInvoiceSuccessfulResponse> + */ + public function bulkCreate(BulkCreateInvoiceRequest $invoices): ApiResponse + { + return $this->connector->send(new BulkCreateInvoiceSaloonRequest($invoices->toRequestArray()))->dto(); + } + + /** + * @param UpdateInvoiceRequest $request + * @return ApiResponse<\HamodaDev\Moyasar\Invoice\Shared\DTOs\Responses\InvoiceSuccessfulResponse> + */ + public function update(UpdateInvoiceRequest $request): ApiResponse + { + return $this->connector->send( + new UpdateInvoiceSaloonRequest($request->getInvoiceId(), $request->toRequestBodyArray()) + )->dto(); + } + + /** + * @param string $invoiceId + * @return ApiResponse<\HamodaDev\Moyasar\Invoice\Shared\DTOs\Responses\InvoiceSuccessfulResponse> + */ + public function cancel(string $invoiceId): ApiResponse + { + return $this->connector->send(new CancelInvoiceSaloonRequest($invoiceId))->dto(); + } +} diff --git a/src/Invoice/Internal/Update/UpdateInvoiceSaloonRequest.php b/src/Invoice/Internal/Update/UpdateInvoiceSaloonRequest.php new file mode 100644 index 0000000..5435f44 --- /dev/null +++ b/src/Invoice/Internal/Update/UpdateInvoiceSaloonRequest.php @@ -0,0 +1,64 @@ +} */ + private readonly array $metadata, + ) { + } + + #[Override] + public function resolveEndpoint(): string + { + return "/invoices/{$this->invoiceId}"; + } + + protected function defaultBody(): array + { + return $this->metadata; + } + + /** + * Summary of createDtoFromResponse + * @param Response $response + * @return ApiResponse + */ + #[Override] + public function createDtoFromResponse(Response $response): ApiResponse + { + $code = $response->status(); + $responseArr = $response->json(); + $responseStr = $response->body(); + $responseObject = null; + + if ($response->successful()) { + InvoiceResponseValidator::for($responseArr, 'response')->validate(); + $responseObject = InvoiceSuccessfulResponse::fromArray($responseArr); + } + + return new ApiResponse( + code: $code, + response: $responseObject, + responseAsString: $responseStr + ); + } +} diff --git a/src/Invoice/InvoiceResource.php b/src/Invoice/InvoiceResource.php deleted file mode 100644 index 456aea0..0000000 --- a/src/Invoice/InvoiceResource.php +++ /dev/null @@ -1,51 +0,0 @@ -connector->send(new GetInvoiceRequest($invoiceId))->dto(); - } - - public function list(): ListInvoicesRequest - { - return new ListInvoicesRequest(); - } - - public function create(CreateInvoiceDTO $dto): InvoiceDTO - { - return $this->connector->send(new CreateInvoiceRequest($dto))->dto(); - } - - public function update(string $invoiceId, UpdateInvoiceDTO $dto): InvoiceDTO - { - return $this->connector->send(new UpdateInvoiceRequest($invoiceId, $dto))->dto(); - } - - public function cancel(string $invoiceId): InvoiceDTO - { - return $this->connector->send(new CancelInvoiceRequest($invoiceId))->dto(); - } - - /** - * @param CreateInvoiceDTO[] $invoices - * @return array{invoices: InvoiceDTO[]} - */ - public function bulkCreate(array $invoices): array - { - return $this->connector->send(new BulkCreateInvoicesRequest($invoices))->dto(); - } -} diff --git a/src/Invoice/Shared/DTOs/Requests/BulkCreateInvoiceRequest.php b/src/Invoice/Shared/DTOs/Requests/BulkCreateInvoiceRequest.php new file mode 100644 index 0000000..55738d8 --- /dev/null +++ b/src/Invoice/Shared/DTOs/Requests/BulkCreateInvoiceRequest.php @@ -0,0 +1,57 @@ + $inv) { + if (!($inv instanceof CreateInvoiceRequest)) { + throw new InvalidArgumentException("Invalid Invoice at index {$i}"); + } + $result[] = $inv; + } + + return new self($result); + } + + /** + * @return array{invoices: CreateInvoiceRequestArray[]} + */ + public function toRequestArray(): array + { + $invoices = []; + foreach ($this->invoices as $inv) { + $invoices[] = $inv->toRequestArray(); + } + + return ['invoices' => $invoices]; + } + + /** + * @return CreateInvoiceRequest[] + */ + public function getInvoices(): array + { + return $this->invoices; + } +} diff --git a/src/Invoice/Shared/DTOs/Requests/CreateInvoiceRequest.php b/src/Invoice/Shared/DTOs/Requests/CreateInvoiceRequest.php new file mode 100644 index 0000000..144bb60 --- /dev/null +++ b/src/Invoice/Shared/DTOs/Requests/CreateInvoiceRequest.php @@ -0,0 +1,141 @@ + + *} + */ +final class CreateInvoiceRequest +{ + private const string DEFAULT_DATETIME_FORMAT = 'Y-m-d\TH:i:s.v\Z'; + + public function __construct( + private float $amount, + private Currency $currency, + private string $description, + private ?string $callbackUrl = null, + private ?string $successUrl = null, + private ?string $backUrl = null, + private ?DateTimeImmutable $expiredAt = null, + private ?array $metadata = null, + ) { + } + + public static function create( + float $amount, + Currency $currency, + string $description, + ?string $callbackUrl = null, + ?string $successUrl = null, + ?string $backUrl = null, + ?DateTimeImmutable $expiredAt = null, + ?array $metadata = null, + ): self { + return new self( + amount: $amount, + currency: $currency, + description: $description, + callbackUrl: $callbackUrl, + successUrl: $successUrl, + backUrl: $backUrl, + expiredAt: $expiredAt, + metadata: $metadata + ); + } + + public static function createFromArray(array $data): self + { + $mandatoryFields = ['amount', 'currency', 'description']; + foreach ($mandatoryFields as $field) { + if (empty($data[$field])) { + throw new InvalidArgumentException("Field {$field} cannot be empty"); + } + } + + return new self( + amount: $data['amount'], + currency: $data['currency'], + description: $data['description'], + callbackUrl: $data['callbackUrl'] ?? null, + successUrl: $data['successUrl'] ?? null, + backUrl: $data['backUrl'] ?? null, + expiredAt: $data['expiredAt'] ?? null, + metadata: $data['metadata'] ?? null, + ); + } + + /** + * @return CreateInvoiceRequestArray + */ + public function toRequestArray(): array + { + $amount = (int) ($this->amount * pow(10, Currency::minorUnitFor($this->currency))); + $currency = $this->currency->value; + $expiredAt = $this->expiredAt?->format(self::DEFAULT_DATETIME_FORMAT); + + return [ + 'amount' => $amount, + 'currency' => $currency, + 'description' => $this->description, + 'callback_url' => $this->callbackUrl, + 'success_url' => $this->successUrl, + 'back_url' => $this->backUrl, + 'expired_at' => $expiredAt, + 'metadata' => $this->metadata, + ]; + } + + // ==================== GETTERS ==================== // + public function getAmount(): float + { + return $this->amount; + } + + public function getCurrency(): Currency + { + return $this->currency; + } + + public function getdescription(): string + { + return $this->description; + } + + public function getcallbackUrl(): ?string + { + return $this->callbackUrl; + } + + public function getsuccessUrl(): ?string + { + return $this->successUrl; + } + + public function getbackUrl(): ?string + { + return $this->backUrl; + } + + public function getexpiredAt(): ?DateTimeImmutable + { + return $this->expiredAt; + } + + public function getmetadata(): ?array + { + return $this->metadata; + } +} diff --git a/src/Invoice/Shared/DTOs/Requests/ListInvoiceRequest.php b/src/Invoice/Shared/DTOs/Requests/ListInvoiceRequest.php new file mode 100644 index 0000000..aefcffa --- /dev/null +++ b/src/Invoice/Shared/DTOs/Requests/ListInvoiceRequest.php @@ -0,0 +1,53 @@ + $this->page, + ]; + + if ($this->id !== null) { + $result['id'] = $this->id; + } + if ($this->status !== null) { + $result['status'] = $this->status->value; + } + if ($this->after !== null) { + $result['created[gt]'] = $this->after->format(self::DEFAULT_DATETIME_FORMAT); + } + if ($this->before !== null) { + $result['created[lt]'] = $this->before->format(self::DEFAULT_DATETIME_FORMAT); + } + if ($this->metadataKey !== null) { + $result['metadata[key]'] = $this->metadataKey; + } + + return $result; + } + + public function nextPage(): self + { + $new = clone $this; + $new->page++; + return $new; + } +} diff --git a/src/Invoice/Shared/DTOs/Requests/UpdateInvoiceRequest.php b/src/Invoice/Shared/DTOs/Requests/UpdateInvoiceRequest.php new file mode 100644 index 0000000..c2d9a3b --- /dev/null +++ b/src/Invoice/Shared/DTOs/Requests/UpdateInvoiceRequest.php @@ -0,0 +1,47 @@ + */ + private array $metadata + ) { + } + + /** + * renamed this method to `of` instead of `create` to reduce confison `UpdateInvoiceRequest::create(...)` ❌ + * But UpdateInvoiceRequest::create(...)` is more readable + * @param string $invoiceId + * @param array $metadata + * @return UpdateInvoiceRequest + */ + public static function of(string $invoiceId, array $metadata): self + { + return new self($invoiceId, $metadata); + } + + /** + * returns the request body as an associative array + * Note: it doesn't return the invoice id as a key + * @return array{metadata: array} the request body + */ + public function toRequestBodyArray(): array + { + return [ + 'metadata' => $this->metadata + ]; + } + + public function getInvoiceId(): string + { + return $this->invoiceId; + } + + public function getMetadata(): array + { + return $this->metadata; + } +} diff --git a/src/Invoice/Shared/DTOs/Responses/BulkInvoiceSuccessfulResponse.php b/src/Invoice/Shared/DTOs/Responses/BulkInvoiceSuccessfulResponse.php new file mode 100644 index 0000000..6bc11c8 --- /dev/null +++ b/src/Invoice/Shared/DTOs/Responses/BulkInvoiceSuccessfulResponse.php @@ -0,0 +1,45 @@ +> $data + * @return self + */ + public static function fromArrayOfArrays(array $data): self + { + $result = []; + foreach ($data as $inv) { + $result[] = InvoiceSuccessfulResponse::fromArray($inv); + } + + return new self($result); + } + + /** + * @return InvoiceSuccessfulResponse[] + */ + public function getInvoices(): array + { + return $this->invoices; + } +} diff --git a/src/Invoice/Shared/DTOs/Responses/Contract/PaymentSourceDetails.php b/src/Invoice/Shared/DTOs/Responses/Contract/PaymentSourceDetails.php new file mode 100644 index 0000000..923083e --- /dev/null +++ b/src/Invoice/Shared/DTOs/Responses/Contract/PaymentSourceDetails.php @@ -0,0 +1,8 @@ + */ + private array $metadata = [], + /** @var InvoicePayment[] */ + private array $payments = [], + private ?string $callbackUrl = null, + private ?DateTimeImmutable $expiredAt = null, + private ?string $backUrl = null, + private ?string $successUrl = null, + ) { + } + + /** + * @param array $data validated response array + */ + public static function fromArray(array $data): self + { + $invoiceStatus = InvoiceStatus::from($data['status']); + $currency = Currency::from($data['currency']); + $createdAt = new DateTimeImmutable($data['created_at']); + $updatedAt = new DateTimeImmutable($data['updated_at']); + $expiredAt = (\array_key_exists('expired_at', $data) && !empty($data['expired_at'])) + ? new DateTimeImmutable($data['expired_at']) + : null; + $payments = \array_map(InvoicePayment::fromArray(...), $data['payments'] ?? []); + + return new self( + id: $data['id'], + status: $invoiceStatus, + amount: (int) $data['amount'], + currency: $currency, + description: $data['description'], + amountFormat: $data['amount_format'], + url: $data['url'], + createdAt: $createdAt, + updatedAt: $updatedAt, + logoUrl: $data['logo_url'] ?? null, + callbackUrl: $data['callback_url'] ?? null, + expiredAt: $expiredAt, + backUrl: $data['back_url'] ?? null, + successUrl: $data['success_url'] ?? null, + metadata: $data['metadata'] ?? [], + payments: $payments, + ); + } + + // Getters + public function getId(): string + { + return $this->id; + } + + public function getStatus(): InvoiceStatus + { + return $this->status; + } + + public function getAmount(): int + { + return $this->amount; + } + + public function getAmountAsFloat(): float + { + $decimals = Currency::minorUnitFor($this->currency); + $divider = pow(10, $decimals); + return round($this->amount / $divider, $decimals); + } + + public function getCurrency(): Currency + { + return $this->currency; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getAmountFormat(): string + { + return $this->amountFormat; + } + + public function getUrl(): string + { + return $this->url; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function getUpdatedAt(): DateTimeImmutable + { + return $this->updatedAt; + } + + public function getLogoUrl(): ?string + { + return $this->logoUrl; + } + + public function getMetadata(): array + { + return $this->metadata; + } + + public function getPayments(): array + { + return $this->payments; + } + + public function getCallbackUrl(): ?string + { + return $this->callbackUrl; + } + + public function getExpiredAt(): ?DateTimeImmutable + { + return $this->expiredAt; + } + + public function getBackUrl(): ?string + { + return $this->backUrl; + } + + public function getSuccessUrl(): ?string + { + return $this->successUrl; + } +} diff --git a/src/Invoice/Shared/DTOs/Responses/ListInvoiceSuccessfulResponse.php b/src/Invoice/Shared/DTOs/Responses/ListInvoiceSuccessfulResponse.php new file mode 100644 index 0000000..af75f6e --- /dev/null +++ b/src/Invoice/Shared/DTOs/Responses/ListInvoiceSuccessfulResponse.php @@ -0,0 +1,54 @@ +> $data + * @return self + */ + public static function fromArrayOfArrays(array $data, array $meta): self + { + $invoices = []; + foreach ($data as $inv) { + $invoices[] = InvoiceSuccessfulResponse::fromArray($inv); + } + + return new self($invoices, ResponsePaginator::fromArray($meta)); + } + + /** + * @return InvoiceSuccessfulResponse[] + */ + public function getInvoices(): array + { + return $this->invoices; + } + + public function getMeta(): ResponsePaginator + { + return $this->meta; + } +} diff --git a/src/Invoice/Shared/DTOs/Responses/Paginator/ResponsePaginator.php b/src/Invoice/Shared/DTOs/Responses/Paginator/ResponsePaginator.php new file mode 100644 index 0000000..0b84c33 --- /dev/null +++ b/src/Invoice/Shared/DTOs/Responses/Paginator/ResponsePaginator.php @@ -0,0 +1,51 @@ +currentPage; + } + + public function getNextPage(): int + { + return $this->nextPage; + } + + public function getPrevPage(): int + { + return $this->prevPage; + } + + public function getTotalPages(): int + { + return $this->totalPages; + } + + public function getTotalCount(): int + { + return $this->totalCount; + } +} diff --git a/src/Invoice/Shared/DTOs/Responses/Payment/InvoicePayment.php b/src/Invoice/Shared/DTOs/Responses/Payment/InvoicePayment.php new file mode 100644 index 0000000..6e727c5 --- /dev/null +++ b/src/Invoice/Shared/DTOs/Responses/Payment/InvoicePayment.php @@ -0,0 +1,234 @@ + */ + private ?array $metadata = null, + /** @var ?InvoicePaymentSplit[] */ + private ?array $splits = null, + ) { + } + + public static function fromArray(array $data): self + { + $status = PaymentStatus::from($data['status']); + $amount = (int) $data['amount']; + $fee = (int) $data['fee']; + $currency = Currency::from($data['currency']); + $refunded = (int) $data['refunded']; + $captured = (int) $data['captured']; + $refundedAt = (\array_key_exists('refunded_at', $data)) ? new DateTimeImmutable($data['refunded_at']) : null; + $capturedAt = (\array_key_exists('captured_at', $data)) ? new DateTimeImmutable($data['captured_at']) : null; + $voidedAt = (\array_key_exists('voided_at', $data)) ? new DateTimeImmutable($data['voided_at']) : null; + $createdAt = new DateTimeImmutable($data['created_at']); + $updatedAt = new DateTimeImmutable($data['updated_at']); + $metadata = $data['metadata'] ?? null; + $source = InvoicePaymentSource::fromArray($data['source']); + $splits = \array_map(InvoicePaymentSplit::fromArray(...), $data['splits'] ?? []); + + return new self( + id: $data['id'], + status: $status, + amount: $amount, + fee: $fee, + currency: $currency, + refunded: $refunded, + captured: $captured, + amountFormat: $data['amountFormat'], + feeFormat: $data['feeFormat'], + refundedFormat: $data['refundedFormat'], + capturedFormat: $data['capturedFormat'], + ip: $data['ip'], + source: $source, + createdAt: $createdAt, + updatedAt: $updatedAt, + refundedAt: $refundedAt, + capturedAt: $capturedAt, + voidedAt: $voidedAt, + description: $data['description'] ?? null, + invoiceId: $data['invoiceId'] ?? null, + callbackUrl: $data['callbackUrl'] ?? null, + metadata: $metadata, + splits: $splits, + ); + } + + public function getId(): string + { + return $this->id; + } + + public function getStatus(): PaymentStatus + { + return $this->status; + } + + public function getAmount(): int + { + return $this->amount; + } + + public function getAmountAsFloat(): float + { + $decimals = Currency::minorUnitFor($this->currency); + $divider = pow(10, $decimals); + return round($this->amount / $divider, $decimals); + } + + public function getFee(): int + { + return $this->fee; + } + + public function getFeeAsFloat(): float + { + $decimals = Currency::minorUnitFor($this->currency); + $divider = pow(10, $decimals); + return round($this->fee / $divider, $decimals); + } + + public function getCurrency(): Currency + { + return $this->currency; + } + + public function getRefunded(): int + { + return $this->refunded; + } + + public function getRefundedAsFloat(): float + { + $decimals = Currency::minorUnitFor($this->currency); + $divider = pow(10, $decimals); + return round($this->refunded / $divider, $decimals); + } + + public function getCaptured(): int + { + return $this->captured; + } + + public function getCapturedAsFloat(): float + { + $decimals = Currency::minorUnitFor($this->currency); + $divider = pow(10, $decimals); + return round($this->captured / $divider, $decimals); + } + + public function getAmountFormat(): string + { + return $this->amountFormat; + } + + public function getFeeFormat(): string + { + return $this->feeFormat; + } + + public function getRefundedFormat(): string + { + return $this->refundedFormat; + } + + public function getCapturedFormat(): string + { + return $this->capturedFormat; + } + + public function getIp(): string + { + return $this->ip; + } + + public function getSource(): InvoicePaymentSource + { + return $this->source; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function getUpdatedAt(): DateTimeImmutable + { + return $this->updatedAt; + } + + public function getRefundedAt(): ?DateTimeImmutable + { + return $this->refundedAt; + } + + public function getCapturedAt(): ?DateTimeImmutable + { + return $this->capturedAt; + } + + public function getVoidedAt(): ?DateTimeImmutable + { + return $this->voidedAt; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function getInvoiceId(): ?string + { + return $this->invoiceId; + } + + public function getCallbackUrl(): ?string + { + return $this->callbackUrl; + } + + /** + * @return ?array + */ + public function getMetadata(): ?array + { + return $this->metadata; + } + + /** + * @return ?InvoicePaymentSplit[] + */ + public function getSplits(): ?array + { + return $this->splits; + } +} diff --git a/src/Invoice/Shared/DTOs/Responses/Payment/Source/ApplePayPaymentSourceDetails.php b/src/Invoice/Shared/DTOs/Responses/Payment/Source/ApplePayPaymentSourceDetails.php new file mode 100644 index 0000000..910a2e6 --- /dev/null +++ b/src/Invoice/Shared/DTOs/Responses/Payment/Source/ApplePayPaymentSourceDetails.php @@ -0,0 +1,130 @@ +company; + } + + public function getNumber(): string + { + return $this->number; + } + + public function getGatewayId(): string + { + return $this->gatewayId; + } + + public function getReferenceNumber(): string + { + return $this->referenceNumber; + } + + public function getMessage(): string + { + return $this->message; + } + + public function getToken(): string + { + return $this->token; + } + + public function getDpan(): ?string + { + return $this->dpan; + } + + public function getResponseCode(): ?string + { + return $this->responseCode; + } + + public function getAuthorizationCode(): ?string + { + return $this->authorizationCode; + } + + public function getIssuerName(): ?string + { + return $this->issuerName; + } + + public function getIssuerCountry(): ?string + { + return $this->issuerCountry; + } + + public function getIssuerCardType(): ?PaymentCardType + { + return $this->issuerCardType; + } + + public function getIssuerCardCategory(): ?string + { + return $this->issuerCardCategory; + } + + public function getName(): ?string + { + return $this->name; + } +} diff --git a/src/Invoice/Shared/DTOs/Responses/Payment/Source/CreditCardPaymentSourceDetails.php b/src/Invoice/Shared/DTOs/Responses/Payment/Source/CreditCardPaymentSourceDetails.php new file mode 100644 index 0000000..bb8ce62 --- /dev/null +++ b/src/Invoice/Shared/DTOs/Responses/Payment/Source/CreditCardPaymentSourceDetails.php @@ -0,0 +1,128 @@ +company; + } + + public function getName(): string + { + return $this->name; + } + + public function getNumber(): string + { + return $this->number; + } + + public function getGatewayId(): string + { + return $this->gatewayId; + } + + public function getToken(): string + { + return $this->token; + } + + public function getMessage(): string + { + return $this->message; + } + + public function getTransactionUrl(): string + { + return $this->transactionUrl; + } + + public function getReferenceNumber(): string + { + return $this->referenceNumber; + } + + public function getAuthorizationCode(): ?string + { + return $this->authorizationCode; + } + + public function getResponseCode(): ?string + { + return $this->responseCode; + } + + public function getIssuerName(): ?string + { + return $this->issuerName; + } + + public function getIssuerCountry(): ?string + { + return $this->issuerCountry; + } + + public function getIssuerCardType(): ?PaymentCardType + { + return $this->issuerCardType; + } + + public function getIssuerCardCategory(): ?string + { + return $this->issuerCardCategory; + } +} diff --git a/src/Invoice/Shared/DTOs/Responses/Payment/Source/InvoicePaymentSource.php b/src/Invoice/Shared/DTOs/Responses/Payment/Source/InvoicePaymentSource.php new file mode 100644 index 0000000..8ff33d6 --- /dev/null +++ b/src/Invoice/Shared/DTOs/Responses/Payment/Source/InvoicePaymentSource.php @@ -0,0 +1,39 @@ + CreditCardPaymentSourceDetails::from($data), + PaymentSourceType::APPLE_PAY => ApplePayPaymentSourceDetails::from($data), + PaymentSourceType::SAMSUNG_PAY => SamsungPayPaymentSourceDetails::from($data), + PaymentSourceType::STC_PAY => StcPayPaymentSourceDetails::from($data), + }; + + return new self($type, $details); + } + + public function getType(): PaymentSourceType + { + return $this->type; + } + + public function getPaymentSourceDetails(): PaymentSourceDetails + { + return $this->paymentSourceDetails; + } +} diff --git a/src/Invoice/Shared/DTOs/Responses/Payment/Source/SamsungPayPaymentSourceDetails.php b/src/Invoice/Shared/DTOs/Responses/Payment/Source/SamsungPayPaymentSourceDetails.php new file mode 100644 index 0000000..b8834e6 --- /dev/null +++ b/src/Invoice/Shared/DTOs/Responses/Payment/Source/SamsungPayPaymentSourceDetails.php @@ -0,0 +1,130 @@ +company; + } + + public function getNumber(): string + { + return $this->number; + } + + public function getGatewayId(): string + { + return $this->gatewayId; + } + + public function getReferenceNumber(): string + { + return $this->referenceNumber; + } + + public function getMessage(): string + { + return $this->message; + } + + public function getToken(): string + { + return $this->token; + } + + public function getDpan(): ?string + { + return $this->dpan; + } + + public function getResponseCode(): ?string + { + return $this->responseCode; + } + + public function getAuthorizationCode(): ?string + { + return $this->authorizationCode; + } + + public function getIssuerName(): ?string + { + return $this->issuerName; + } + + public function getIssuerCountry(): ?string + { + return $this->issuerCountry; + } + + public function getIssuerCardType(): ?PaymentCardType + { + return $this->issuerCardType; + } + + public function getIssuerCardCategory(): ?string + { + return $this->issuerCardCategory; + } + + public function getName(): ?string + { + return $this->name; + } +} diff --git a/src/Invoice/Shared/DTOs/Responses/Payment/Source/StcPayPaymentSourceDetails.php b/src/Invoice/Shared/DTOs/Responses/Payment/Source/StcPayPaymentSourceDetails.php new file mode 100644 index 0000000..df63a67 --- /dev/null +++ b/src/Invoice/Shared/DTOs/Responses/Payment/Source/StcPayPaymentSourceDetails.php @@ -0,0 +1,67 @@ +mobile; + } + + public function getReferenceNumber(): string + { + return $this->referenceNumber; + } + + public function getCashier(): ?string + { + return $this->cashier; + } + + public function getBranch(): ?string + { + return $this->branch; + } + + public function getTransactionUrl(): ?string + { + return $this->transactionUrl; + } + + public function getMessage(): ?string + { + return $this->message; + } +} diff --git a/src/Invoice/Shared/DTOs/Responses/Payment/Split/InvoicePaymentSplit.php b/src/Invoice/Shared/DTOs/Responses/Payment/Split/InvoicePaymentSplit.php new file mode 100644 index 0000000..83a0f07 --- /dev/null +++ b/src/Invoice/Shared/DTOs/Responses/Payment/Split/InvoicePaymentSplit.php @@ -0,0 +1,72 @@ +amount; + } + + public function getRecipientType(): SplitRecipientType + { + return $this->recipientType; + } + + public function getRecipientId(): string + { + return $this->recipientId; + } + + public function getFeeSource(): bool + { + return $this->feeSource; + } + + public function getRefundable(): bool + { + return $this->refundable; + } + + public function getReference(): ?string + { + return $this->reference; + } + + public function getDescription(): ?string + { + return $this->description; + } +} diff --git a/src/Moyasar.php b/src/Moyasar.php index 8f2e249..694b92e 100644 --- a/src/Moyasar.php +++ b/src/Moyasar.php @@ -2,11 +2,12 @@ namespace HamodaDev\Moyasar; -use HamodaDev\Moyasar\Invoice\InvoiceResource; +use HamodaDev\Moyasar\Invoice\Internal\InvoiceResource; use HamodaDev\Moyasar\Payment\PaymentResource; use Saloon\Http\Auth\BasicAuthenticator; use Saloon\Http\Connector; +// TODO: make it singleton class Moyasar extends Connector { public function __construct( diff --git a/src/Shared/Const/Currency.php b/src/Shared/Const/Currency.php new file mode 100644 index 0000000..1a820f7 --- /dev/null +++ b/src/Shared/Const/Currency.php @@ -0,0 +1,200 @@ + 3, + + Currency::BIF, Currency::CLP, Currency::DJF, Currency::GNF, Currency::ISK, Currency::JPY, Currency::KMF, + Currency::KRW, Currency::PYG, Currency::RWF, Currency::UGX, Currency::UYI, Currency::VND, Currency::VUV, + Currency::XAF, Currency::XOF, Currency::XPF => 0, + + default => 2 + }; + } +} diff --git a/src/Shared/DTOs/ApiResponse.php b/src/Shared/DTOs/ApiResponse.php new file mode 100644 index 0000000..390e10f --- /dev/null +++ b/src/Shared/DTOs/ApiResponse.php @@ -0,0 +1,40 @@ +code >= 200 && $this->code < 300; + } + + public function getCode(): int + { + return $this->code; + } + + public function getResponseAsString(): string + { + return $this->responseAsString; + } + + /** + * @return T|null + */ + public function getResponse(): ?object + { + return $this->response; + } +} diff --git a/tests/Config/MockResponses.php b/tests/Config/MockResponses.php index 5de949d..6915014 100644 --- a/tests/Config/MockResponses.php +++ b/tests/Config/MockResponses.php @@ -4,12 +4,12 @@ use Saloon\Http\Faking\MockResponse; use Tests\Config\Samples\InvoiceSamples; -use HamodaDev\Moyasar\Invoice\APIs\CancelInvoiceRequest; -use HamodaDev\Moyasar\Invoice\APIs\BulkCreateInvoicesRequest; -use HamodaDev\Moyasar\Invoice\APIs\CreateInvoiceRequest; -use HamodaDev\Moyasar\Invoice\APIs\GetInvoiceRequest; -use HamodaDev\Moyasar\Invoice\APIs\ListInvoicesRequest; -use HamodaDev\Moyasar\Invoice\APIs\UpdateInvoiceRequest; +use HamodaDev\Moyasar\Invoice\Internal\Create\CreateInvoiceSaloonRequest; +use HamodaDev\Moyasar\Invoice\Internal\Create\BulkCreateInvoiceSaloonRequest; +use HamodaDev\Moyasar\Invoice\Internal\Get\GetInvoiceSaloonRequest; +use HamodaDev\Moyasar\Invoice\Internal\Get\ListInvoiceSaloonRequest; +use HamodaDev\Moyasar\Invoice\Internal\Cancel\CancelInvoiceSaloonRequest; +use HamodaDev\Moyasar\Invoice\Internal\Update\UpdateInvoiceSaloonRequest; class MockResponses { @@ -24,23 +24,29 @@ public static function getAll(): array private static function invoiceResponses(): array { return [ - CreateInvoiceRequest::class => MockResponse::make(body: InvoiceSamples::TEST_INVOICE), - BulkCreateInvoicesRequest::class => MockResponse::make(body: [ + CreateInvoiceSaloonRequest::class => MockResponse::make(body: InvoiceSamples::TEST_INVOICE), + BulkCreateInvoiceSaloonRequest::class => MockResponse::make(body: [ 'invoices' => [ InvoiceSamples::TEST_INVOICE, InvoiceSamples::TEST_INVOICE_2, ], ]), - GetInvoiceRequest::class => MockResponse::make(body: InvoiceSamples::TEST_INVOICE_3), - ListInvoicesRequest::class => MockResponse::make(body: [ + + GetInvoiceSaloonRequest::class => MockResponse::make(body: InvoiceSamples::TEST_INVOICE_3), + ListInvoiceSaloonRequest::class => MockResponse::make(body: [ 'invoices' => [ InvoiceSamples::TEST_INVOICE, InvoiceSamples::TEST_INVOICE_2, InvoiceSamples::TEST_INVOICE_3, ], + 'meta' => [ + 'current_page' => 1, + 'total_pages' => 1, + 'total_count' => 3, + ], ]), - UpdateInvoiceRequest::class => MockResponse::make(body: InvoiceSamples::TEST_INVOICE), - CancelInvoiceRequest::class => MockResponse::make(body: InvoiceSamples::CANCELED_TEST_INVOICE), + UpdateInvoiceSaloonRequest::class => MockResponse::make(body: InvoiceSamples::TEST_INVOICE), + CancelInvoiceSaloonRequest::class => MockResponse::make(body: InvoiceSamples::CANCELED_TEST_INVOICE), ]; } } diff --git a/tests/Config/Samples/InvoiceSamples.php b/tests/Config/Samples/InvoiceSamples.php index 102bb84..f74809a 100644 --- a/tests/Config/Samples/InvoiceSamples.php +++ b/tests/Config/Samples/InvoiceSamples.php @@ -16,6 +16,8 @@ class InvoiceSamples 'description' => 'test order', 'amount_format' => '10.00 USD', 'url' => self::MOCK_URL, + 'created_at' => '2026-05-14 11:46:52', + 'updated_at' => '2026-05-14 11:46:52', 'metadata' => [ 'order_id' => '1234' ], @@ -30,6 +32,8 @@ class InvoiceSamples 'description' => 'test order', 'amount_format' => '10.00 USD', 'url' => self::MOCK_URL, + 'created_at' => '2026-05-12 11:46:52', + 'updated_at' => '2026-05-12 11:46:52', 'metadata' => [ 'order_id' => '1234' ], @@ -44,6 +48,11 @@ class InvoiceSamples 'description' => 'test order 22', 'amount_format' => '10.00 SAR', 'url' => self::MOCK_URL, + 'created_at' => '2026-05-10 11:46:52', + 'updated_at' => '2026-05-10 11:46:52', + 'metadata' => [ + 'order_id' => '5678' + ], ]; /** @var array */ @@ -55,5 +64,10 @@ class InvoiceSamples 'description' => 'test order #333', 'amount_format' => '69.00 EGP', 'url' => self::MOCK_URL, + 'created_at' => '2026-05-15 11:46:52', + 'updated_at' => '2026-05-15 11:46:52', + 'metadata' => [ + 'order_id' => '91011' + ], ]; } diff --git a/tests/Feature/Invoice/CancelInvoiceTest.php b/tests/Feature/Invoice/CancelInvoiceTest.php index d164ef1..89215ef 100644 --- a/tests/Feature/Invoice/CancelInvoiceTest.php +++ b/tests/Feature/Invoice/CancelInvoiceTest.php @@ -1,7 +1,9 @@ validateEnvIsSet()); @@ -10,13 +12,17 @@ // arrange $moyasar = MoyasarInitializer::getInstance()->getMoyasar(); - $invoice = $moyasar->invoice()->create(CreateInvoiceDTO::fromArray(mockCreateInvoiceDTO())); + $invoice = $moyasar->invoice()->create(new CreateInvoiceRequest( + amount: 80, + currency: Currency::SAR, + description: 'Order #1234' + ))->getResponse(); // act - $cancelled = $moyasar->invoice()->cancel($invoice->id); + $cancelled = $moyasar->invoice()->cancel($invoice->getId())->getResponse(); // assert - expect($cancelled)->toBeInstanceOf(InvoiceDTO::class) - ->and($cancelled->id)->toBe($invoice->id) - ->and($cancelled->status)->toBe('canceled'); + expect($cancelled)->toBeInstanceOf(InvoiceSuccessfulResponse::class) + ->and($cancelled->getId())->toBe($invoice->getId()) + ->and($cancelled->getStatus())->toBe(InvoiceStatus::CANCELED); }); diff --git a/tests/Feature/Invoice/CreateInvoiceTest.php b/tests/Feature/Invoice/CreateInvoiceTest.php index 0b0e499..9bf42ad 100644 --- a/tests/Feature/Invoice/CreateInvoiceTest.php +++ b/tests/Feature/Invoice/CreateInvoiceTest.php @@ -1,7 +1,10 @@ getMoyasar(); // act - $invoice = $moyasar->invoice()->create(new CreateInvoiceDTO( - amount: 1000, - currency: 'USD', + $invoice = $moyasar->invoice()->create(new CreateInvoiceRequest( + amount: 10, + currency: Currency::USD, description: 'Order #1234', callbackUrl: 'https://example.com/webhooks/moyasar', )); // assert - expect($invoice)->toBeInstanceOf(InvoiceDTO::class) - ->and($invoice->id)->not->toBeEmpty() - ->and($invoice->status)->toBe('initiated') - ->and($invoice->amount)->toBe(1000) - ->and($invoice->currency)->toBe('USD') - ->and($invoice->url)->toStartWith('https://'); + $response = $invoice->getResponse(); + expect($response)->toBeInstanceOf(InvoiceSuccessfulResponse::class) + ->and($response->getId())->not->toBeEmpty() + ->and($response->getStatus())->toBe(InvoiceStatus::INITIATED) + ->and($response->getAmountAsFloat())->toBe(10.0) + ->and($response->getCurrency())->toBe(Currency::USD) + ->and($response->getUrl())->toStartWith('https://'); }); it('Bulk creates invoices', function () { @@ -33,14 +37,24 @@ $moyasar = MoyasarInitializer::getInstance()->getMoyasar(); // act - $invoices = $moyasar->invoice()->bulkCreate([ - CreateInvoiceDTO::fromArray(mockCreateInvoiceDTO(['amount' => 69_00, 'currency' => 'USD'])), - CreateInvoiceDTO::fromArray(mockCreateInvoiceDTO(['amount' => 70_00, 'currency' => 'EGP'])), + $request = BulkCreateInvoiceRequest::create([ + new CreateInvoiceRequest( + amount: 69, + currency: Currency::USD, + description: 'Order #1111' + ), + new CreateInvoiceRequest( + amount: 70, + currency: Currency::EGP, + description: 'Order #2222' + ) ]); + $invoices = $moyasar->invoice()->bulkCreate($request)->getResponse()?->getInvoices(); + // assert - expect($invoices['invoices'])->toBeArray()->toHaveCount(2) - ->and($invoices['invoices'][0])->toBeInstanceOf(InvoiceDTO::class) - ->and($invoices['invoices'][0]->id)->not->toBeEmpty() - ->and($invoices['invoices'][1]->id)->not->toBeEmpty(); + expect($invoices)->toBeArray()->toHaveCount(2) + ->and($invoices[0])->toBeInstanceOf(InvoiceSuccessfulResponse::class) + ->and($invoices[0]->getId())->not->toBeEmpty() + ->and($invoices[1]->getId())->not->toBeEmpty(); }); diff --git a/tests/Feature/Invoice/GetInvoiceTest.php b/tests/Feature/Invoice/GetInvoiceTest.php index c1ee8d7..3804864 100644 --- a/tests/Feature/Invoice/GetInvoiceTest.php +++ b/tests/Feature/Invoice/GetInvoiceTest.php @@ -1,6 +1,8 @@ validateEnvIsSet()); @@ -10,12 +12,12 @@ $moyasar = MoyasarInitializer::getInstance()->getMoyasar(); // act - $invoice = $moyasar->invoice()->get(getenv('MOYASAR_SAMPLE_INITIATED_INVOICE_ID') ?: '91011'); + $invoice = $moyasar->invoice()->get(getenv('MOYASAR_SAMPLE_INITIATED_INVOICE_ID') ?: '91011')->getResponse(); // assert expect($invoice) - ->toBeInstanceOf(InvoiceDTO::class) - ->and($invoice->status)->toBe('initiated'); + ->toBeInstanceOf(InvoiceSuccessfulResponse::class) + ->and($invoice->getStatus())->toBe(InvoiceStatus::INITIATED); }); it('lists invoices', function () { @@ -23,9 +25,9 @@ $moyasar = MoyasarInitializer::getInstance()->getMoyasar(); // act - $invoices = iterator_to_array($moyasar->invoice()->list()->paginate($moyasar)->items()); + $invoices = $moyasar->invoice()->list(new ListInvoiceRequest())->getResponse()->getInvoices(); // assert expect($invoices)->not->toBeEmpty() - ->and($invoices[0])->toBeInstanceOf(InvoiceDTO::class); + ->and($invoices[0])->toBeInstanceOf(InvoiceSuccessfulResponse::class); }); diff --git a/tests/Feature/Invoice/UpdateInvoiceTest.php b/tests/Feature/Invoice/UpdateInvoiceTest.php index d669865..5f90f07 100644 --- a/tests/Feature/Invoice/UpdateInvoiceTest.php +++ b/tests/Feature/Invoice/UpdateInvoiceTest.php @@ -1,8 +1,9 @@ validateEnvIsSet()); @@ -11,15 +12,20 @@ // arrange $moyasar = MoyasarInitializer::getInstance()->getMoyasar(); - $invoice = $moyasar->invoice()->create(CreateInvoiceDTO::fromArray(mockCreateInvoiceDTO())); + $invoice = $moyasar->invoice()->create(new CreateInvoiceRequest( + amount: 10, + currency: Currency::USD, + description: 'Order #1234', + callbackUrl: 'https://example.com/webhooks/moyasar', + ))->getResponse(); // act - $updated = $moyasar->invoice()->update($invoice->id, new UpdateInvoiceDTO( - metadata: ['order_id' => '1234'], - )); + $updated = $moyasar->invoice() + ->update(UpdateInvoiceRequest::of($invoice->getId(), ['order_id' => '1234'])) + ->getResponse(); // assert - expect($updated)->toBeInstanceOf(InvoiceDTO::class) - ->and($updated->id)->toBe($invoice->id) - ->and($updated->metadata)->toBe(['order_id' => '1234']); + expect($updated)->toBeInstanceOf(InvoiceSuccessfulResponse::class) + ->and($updated->getId())->toBe($invoice->getId()) + ->and($updated->getMetadata())->toBe(['order_id' => '1234']); }); diff --git a/tests/Pest.php b/tests/Pest.php index 6a1d083..d1ecb17 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -29,7 +29,7 @@ */ uses() - ->beforeEach(fn () => MockClient::destroyGlobal()) + ->beforeEach(fn() => MockClient::destroyGlobal()) ->in(__DIR__); function validateEnvIsSet()