mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-03 06:09:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
285594e7e9 | ||
|
|
34f6854bdd
|
||
|
|
3acd36ca29
|
||
|
|
a0470111fa | ||
|
|
934b1c5c3f
|
||
|
|
6a568a5354 | ||
|
|
5349ee81f6
|
||
|
|
60b4b80f17 | ||
|
|
1f645b50bc
|
No files matched your search
@@ -157,6 +157,63 @@ Each order has `status` (`processing` = current month, still collecting; `pendin
|
||||
paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null =
|
||||
no more automatic retries) and `last_payment_error`.
|
||||
|
||||
### Checkout sessions (the user confirms several subscriptions at once)
|
||||
|
||||
Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID,
|
||||
all of them in one step: a checkout session bundles new subscriptions (or existing drafts of the
|
||||
app, `['id' => …]`) of one user. The user sees every item with its price and the total of the first
|
||||
period, confirms (all subscriptions start, or none) or cancels, and returns to `return_url`.
|
||||
Open sessions expire after 24 hours with their drafts. Trusted apps can still check out directly;
|
||||
sessions are opt-in.
|
||||
|
||||
Example: the Console's builder ("Jetzt kaufen") with several products and coupon prices:
|
||||
|
||||
```php
|
||||
use Anikeen\Id\Exceptions\ApiException;
|
||||
|
||||
$session = $request->user()->appCheckoutSessions()->create([ // app token; or ->checkoutSessions() with the user's token
|
||||
'return_url' => route('builder.done'), // same origin as one of your redirect URIs
|
||||
'metadata' => ['cart' => $cart->id], // returned as is
|
||||
'idempotency_key' => "cart:{$cart->id}", // retries return the same session
|
||||
'subscriptions' => $cart->items->map(fn ($item) => [
|
||||
'group' => $team->id,
|
||||
'name' => $item->product->name,
|
||||
'description' => $item->label,
|
||||
'unit' => $item->unit, // hour, day, week, month, year
|
||||
'price' => $item->priceAfterCoupon(), // net, per unit
|
||||
'payload' => ['cart_item' => $item->id],
|
||||
])->all(),
|
||||
]);
|
||||
|
||||
$cart->update(['checkout_session_id' => $session->id, 'subscription_ids' => $session->subscriptionIds()]);
|
||||
|
||||
return redirect($session->confirmationUrl());
|
||||
// later: https://console.test/builder/done?status=completed&checkout_session=01K…
|
||||
```
|
||||
|
||||
Provision when the webhook arrives (or after checking the session through the API; never trust the
|
||||
query string):
|
||||
|
||||
```php
|
||||
use Anikeen\Id\Webhooks\EventType;
|
||||
|
||||
match ($event['type']) {
|
||||
EventType::CHECKOUT_COMPLETED => Provisioner::provision($event['data']['object']['subscriptions']), // all running
|
||||
EventType::CHECKOUT_CANCELLED, EventType::CHECKOUT_EXPIRED => Cart::release($event['data']['object']['metadata']['cart'] ?? null),
|
||||
default => null,
|
||||
};
|
||||
|
||||
app(AnikeenId::class)->appCheckoutSessions()->find($id)?->isCompleted(); // any user's session
|
||||
$session->refresh()->subscriptions(); // AppSubscription objects
|
||||
$session->cancel(); // withdraw an open session and its drafts (e.g. the cart changed)
|
||||
```
|
||||
|
||||
While a session is open, its subscriptions can't be checked out or repriced by the app (409
|
||||
`in_checkout_session` / `not_changeable`). The session's `status` is `open`, `completed`,
|
||||
`cancelled` or `expired` (`isOpen()`, `isCompleted()`, `isCancelled()` for cancelled or expired).
|
||||
`subscription.activated` / `subscription.cancelled` are still sent per subscription, before the
|
||||
`checkout.*` event of the session.
|
||||
|
||||
### Selling as a third-party app (user confirms on Anikeen ID)
|
||||
|
||||
Apps that are not trusted can create orders and subscriptions for the signed-in user (user token),
|
||||
@@ -196,17 +253,39 @@ $order = $user->orders()->create([
|
||||
]);
|
||||
```
|
||||
|
||||
Once a paid order is sent, create a shipment. Anikeen ID adds its delivery note to the user's
|
||||
order documents (with the tracking link), mails it to the user (`'notify_customer' => false` if
|
||||
your shop sends its own mail) and sends `order.shipped` to your webhook endpoints.
|
||||
Without `items`, everything physical that is left is shipped; partial deliveries name the items:
|
||||
|
||||
```php
|
||||
$order = $user->orders()->find($orderId);
|
||||
|
||||
$shipment = $order->shipments()->create([
|
||||
'carrier' => 'DHL',
|
||||
'tracking_number' => '00340434161234567890',
|
||||
'tracking_url' => 'https://www.dhl.de/…?piececode=00340434161234567890',
|
||||
// 'items' => [['order_item_id' => $itemId, 'units' => 1]],
|
||||
]);
|
||||
|
||||
$shipment->number; // LS-2026-0001
|
||||
$shipment->deliveryNote(); // the delivery note as PDF
|
||||
```
|
||||
|
||||
Only the app that created the order (or a trusted app) can ship it, and only once it is paid.
|
||||
|
||||
`checkout()` on such a draft throws `ConfirmationRequiredException`; its `confirmationUrl`
|
||||
points to the same page. Trusted apps keep checking out directly.
|
||||
|
||||
### Webhooks
|
||||
|
||||
Anikeen ID sends `order.*` and `subscription.*` events to the webhook endpoints of your app (set up
|
||||
Anikeen ID sends `order.*`, `subscription.*` and `checkout.*` events to the webhook endpoints of your app (set up
|
||||
in the Anikeen ID admin panel, which also shows the signing secret `whsec_…`). Verify each request
|
||||
with the raw body and answer 2xx within 10 seconds; failed deliveries are retried for about three days.
|
||||
|
||||
```php
|
||||
use Anikeen\Id\Exceptions\SignatureVerificationException;
|
||||
use Anikeen\Id\Webhooks\EventType;
|
||||
use Anikeen\Id\Webhooks\Webhook;
|
||||
|
||||
Route::post('webhooks/anikeen', function (Request $request) {
|
||||
@@ -217,8 +296,9 @@ Route::post('webhooks/anikeen', function (Request $request) {
|
||||
}
|
||||
|
||||
match ($event['type']) {
|
||||
'order.completed' => Shop::fulfil($event['data']['object']['id']),
|
||||
'subscription.cancelled' => Panel::suspend($event['data']['object']['id']),
|
||||
EventType::ORDER_COMPLETED => Shop::fulfil($event['data']['object']['id']),
|
||||
EventType::SUBSCRIPTION_CANCELLED => Panel::suspend($event['data']['object']['id']),
|
||||
EventType::CHECKOUT_COMPLETED => Panel::provision($event['data']['object']['subscription_ids']),
|
||||
default => null,
|
||||
};
|
||||
|
||||
@@ -450,6 +530,7 @@ public function shippingPriceList(): Result
|
||||
|
||||
```php
|
||||
public function appOrders(): AppOrders
|
||||
public function appCheckoutSessions(): AppCheckoutSessions
|
||||
public function createOrderForUser(string $userId, array $attributes): Result
|
||||
public function createTransactionForUser(string $userId, array $attributes): Result
|
||||
```
|
||||
@@ -552,6 +633,8 @@ public function getBillable(): Billable
|
||||
```php
|
||||
public function subscriptions(): Subscriptions
|
||||
public function appSubscriptions(): AppSubscriptions
|
||||
public function appCheckoutSessions(): AppCheckoutSessions
|
||||
public function checkoutSessions(): CheckoutSessions
|
||||
public function appOrders(): AppOrders
|
||||
public function setBillable(Billable|Model $billable): self
|
||||
public function getBillable(): Billable
|
||||
|
||||
+62
-3
@@ -157,6 +157,63 @@ Each order has `status` (`processing` = current month, still collecting; `pendin
|
||||
paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null =
|
||||
no more automatic retries) and `last_payment_error`.
|
||||
|
||||
### Checkout sessions (the user confirms several subscriptions at once)
|
||||
|
||||
Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID,
|
||||
all of them in one step: a checkout session bundles new subscriptions (or existing drafts of the
|
||||
app, `['id' => …]`) of one user. The user sees every item with its price and the total of the first
|
||||
period, confirms (all subscriptions start, or none) or cancels, and returns to `return_url`.
|
||||
Open sessions expire after 24 hours with their drafts. Trusted apps can still check out directly;
|
||||
sessions are opt-in.
|
||||
|
||||
Example: the Console's builder ("Jetzt kaufen") with several products and coupon prices:
|
||||
|
||||
```php
|
||||
use Anikeen\Id\Exceptions\ApiException;
|
||||
|
||||
$session = $request->user()->appCheckoutSessions()->create([ // app token; or ->checkoutSessions() with the user's token
|
||||
'return_url' => route('builder.done'), // same origin as one of your redirect URIs
|
||||
'metadata' => ['cart' => $cart->id], // returned as is
|
||||
'idempotency_key' => "cart:{$cart->id}", // retries return the same session
|
||||
'subscriptions' => $cart->items->map(fn ($item) => [
|
||||
'group' => $team->id,
|
||||
'name' => $item->product->name,
|
||||
'description' => $item->label,
|
||||
'unit' => $item->unit, // hour, day, week, month, year
|
||||
'price' => $item->priceAfterCoupon(), // net, per unit
|
||||
'payload' => ['cart_item' => $item->id],
|
||||
])->all(),
|
||||
]);
|
||||
|
||||
$cart->update(['checkout_session_id' => $session->id, 'subscription_ids' => $session->subscriptionIds()]);
|
||||
|
||||
return redirect($session->confirmationUrl());
|
||||
// later: https://console.test/builder/done?status=completed&checkout_session=01K…
|
||||
```
|
||||
|
||||
Provision when the webhook arrives (or after checking the session through the API; never trust the
|
||||
query string):
|
||||
|
||||
```php
|
||||
use Anikeen\Id\Webhooks\EventType;
|
||||
|
||||
match ($event['type']) {
|
||||
EventType::CHECKOUT_COMPLETED => Provisioner::provision($event['data']['object']['subscriptions']), // all running
|
||||
EventType::CHECKOUT_CANCELLED, EventType::CHECKOUT_EXPIRED => Cart::release($event['data']['object']['metadata']['cart'] ?? null),
|
||||
default => null,
|
||||
};
|
||||
|
||||
app(AnikeenId::class)->appCheckoutSessions()->find($id)?->isCompleted(); // any user's session
|
||||
$session->refresh()->subscriptions(); // AppSubscription objects
|
||||
$session->cancel(); // withdraw an open session and its drafts (e.g. the cart changed)
|
||||
```
|
||||
|
||||
While a session is open, its subscriptions can't be checked out or repriced by the app (409
|
||||
`in_checkout_session` / `not_changeable`). The session's `status` is `open`, `completed`,
|
||||
`cancelled` or `expired` (`isOpen()`, `isCompleted()`, `isCancelled()` for cancelled or expired).
|
||||
`subscription.activated` / `subscription.cancelled` are still sent per subscription, before the
|
||||
`checkout.*` event of the session.
|
||||
|
||||
### Selling as a third-party app (user confirms on Anikeen ID)
|
||||
|
||||
Apps that are not trusted can create orders and subscriptions for the signed-in user (user token),
|
||||
@@ -185,12 +242,13 @@ points to the same page. Trusted apps keep checking out directly.
|
||||
|
||||
### Webhooks
|
||||
|
||||
Anikeen ID sends `order.*` and `subscription.*` events to the webhook endpoints of your app (set up
|
||||
Anikeen ID sends `order.*`, `subscription.*` and `checkout.*` events to the webhook endpoints of your app (set up
|
||||
in the Anikeen ID admin panel, which also shows the signing secret `whsec_…`). Verify each request
|
||||
with the raw body and answer 2xx within 10 seconds; failed deliveries are retried for about three days.
|
||||
|
||||
```php
|
||||
use Anikeen\Id\Exceptions\SignatureVerificationException;
|
||||
use Anikeen\Id\Webhooks\EventType;
|
||||
use Anikeen\Id\Webhooks\Webhook;
|
||||
|
||||
Route::post('webhooks/anikeen', function (Request $request) {
|
||||
@@ -201,8 +259,9 @@ Route::post('webhooks/anikeen', function (Request $request) {
|
||||
}
|
||||
|
||||
match ($event['type']) {
|
||||
'order.completed' => Shop::fulfil($event['data']['object']['id']),
|
||||
'subscription.cancelled' => Panel::suspend($event['data']['object']['id']),
|
||||
EventType::ORDER_COMPLETED => Shop::fulfil($event['data']['object']['id']),
|
||||
EventType::SUBSCRIPTION_CANCELLED => Panel::suspend($event['data']['object']['id']),
|
||||
EventType::CHECKOUT_COMPLETED => Panel::provision($event['data']['object']['subscription_ids']),
|
||||
default => null,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Anikeen\Id\Concerns;
|
||||
|
||||
/**
|
||||
* Reading a checkout session (see AppCheckoutSession and CheckoutSession).
|
||||
*/
|
||||
trait IsCheckoutSession
|
||||
{
|
||||
/**
|
||||
* Where the user confirms (or cancels) all of the session's subscriptions, or null once the
|
||||
* session is no longer open. The user then returns to the session's `return_url` with
|
||||
* `status` (completed or cancelled when confirming or cancelling; expired only from the
|
||||
* "back to app" link of a session that had expired) and `checkout_session` (the id). Check the
|
||||
* status through the API or the checkout.* webhooks; don't trust the query string.
|
||||
*/
|
||||
public function confirmationUrl(): ?string
|
||||
{
|
||||
return $this->confirmation_url ?: null;
|
||||
}
|
||||
|
||||
public function isOpen(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmed: all of its subscriptions are running.
|
||||
*/
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_COMPLETED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelled or expired: all of its drafts were cancelled.
|
||||
*/
|
||||
public function isCancelled(): bool
|
||||
{
|
||||
return in_array($this->status, [self::STATUS_CANCELLED, self::STATUS_EXPIRED], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function subscriptionIds(): array
|
||||
{
|
||||
return array_values((array) ($this->subscription_ids ?? []));
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace Anikeen\Id\Concerns;
|
||||
|
||||
use Anikeen\Id\Resources\AppCheckoutSessions;
|
||||
use Anikeen\Id\Resources\AppOrders;
|
||||
use Anikeen\Id\Resources\AppSubscriptions;
|
||||
use Anikeen\Id\Resources\CheckoutSessions;
|
||||
use Anikeen\Id\Resources\Subscriptions;
|
||||
use Throwable;
|
||||
|
||||
@@ -37,6 +39,24 @@ trait ManagesSubscriptions
|
||||
return new AppSubscriptions($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout sessions for the user, created with the app's own token (trusted apps only, scope
|
||||
* subscriptions:client): the user confirms several subscriptions at once on Anikeen ID.
|
||||
*/
|
||||
public function appCheckoutSessions(): AppCheckoutSessions
|
||||
{
|
||||
return new AppCheckoutSessions($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout sessions with the user's own access token (any app): the user confirms several
|
||||
* subscriptions at once on Anikeen ID.
|
||||
*/
|
||||
public function checkoutSessions(): CheckoutSessions
|
||||
{
|
||||
return new CheckoutSessions($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's orders that bill the app's subscriptions, read with the app's own token
|
||||
* (trusted apps only, scope orders:client), e.g. ->pending() for unpaid ones.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Anikeen\Id\Concerns;
|
||||
|
||||
use Anikeen\Id\Resources\AppCheckoutSessions;
|
||||
use Anikeen\Id\Resources\AppOrders;
|
||||
use Anikeen\Id\Result;
|
||||
use Throwable;
|
||||
@@ -19,6 +20,15 @@ trait ManagesUserBilling
|
||||
return new AppOrders();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout sessions of the app for any user, e.g. to look one up from a checkout.* webhook
|
||||
* (create() then needs a user_id).
|
||||
*/
|
||||
public function appCheckoutSessions(): AppCheckoutSessions
|
||||
{
|
||||
return new AppCheckoutSessions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an order for a user (VAT from the billing address). With `checkout`, it is paid right
|
||||
* away: with the given `transaction` (a payment captured elsewhere) or by charging the user's
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Anikeen\Id\Resources;
|
||||
|
||||
use Anikeen\Id\Concerns\IsCheckoutSession;
|
||||
use Anikeen\Id\Exceptions\ApiException;
|
||||
use Anikeen\Id\Support\AppRequest;
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* A checkout session created with the app's own token, see AppCheckoutSessions.
|
||||
*
|
||||
* @property string $id
|
||||
* @property string $user_id
|
||||
* @property ?string $oauth_client_id
|
||||
* @property string $status open, completed, cancelled or expired
|
||||
* @property ?string $return_url
|
||||
* @property ?object $metadata
|
||||
* @property ?string $confirmation_url
|
||||
* @property list<string> $subscription_ids
|
||||
* @property list<object> $subscriptions
|
||||
* @property ?string $expires_at
|
||||
* @property ?string $completed_at
|
||||
* @property ?string $cancelled_at
|
||||
* @property ?string $created_at
|
||||
*/
|
||||
class AppCheckoutSession implements JsonSerializable
|
||||
{
|
||||
use IsCheckoutSession;
|
||||
|
||||
public const STATUS_OPEN = 'open';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
|
||||
public function __construct(protected object $data)
|
||||
{
|
||||
}
|
||||
|
||||
public function __get(string $name): mixed
|
||||
{
|
||||
return $this->data->{$name} ?? null;
|
||||
}
|
||||
|
||||
public function __isset(string $name): bool
|
||||
{
|
||||
return isset($this->data->{$name});
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's subscriptions (drafts until the user confirms).
|
||||
*
|
||||
* @return list<AppSubscription>
|
||||
*/
|
||||
public function subscriptions(): array
|
||||
{
|
||||
return array_map(fn (object $subscription) => new AppSubscription($subscription), array_values((array) ($this->data->subscriptions ?? [])));
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw an open session (e.g. the cart changed): it and its drafts are cancelled.
|
||||
*
|
||||
* @throws ApiException 409 "checkout_session_completed", "checkout_session_cancelled", "checkout_session_expired"
|
||||
*/
|
||||
public function cancel(): self
|
||||
{
|
||||
return $this->send('POST', '/cancel');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiException
|
||||
*/
|
||||
public function refresh(): self
|
||||
{
|
||||
return $this->send('GET', '');
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return (array) $this->data;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiException
|
||||
*/
|
||||
private function send(string $method, string $action): self
|
||||
{
|
||||
$this->data = AppRequest::send($method, sprintf('v1/client/checkout-sessions/%s%s', $this->id, $action))->data;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Anikeen\Id\Resources;
|
||||
|
||||
use Anikeen\Id\Contracts\Billable;
|
||||
use Anikeen\Id\Exceptions\ApiException;
|
||||
use Anikeen\Id\Support\AppRequest;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Checkout sessions created with the app's own token (trusted apps, scope `subscriptions:client`):
|
||||
* several subscriptions of one user that the user confirms together on Anikeen ID, instead of the
|
||||
* app checking them out itself. For one user ($user->appCheckoutSessions()) or, to look sessions
|
||||
* up e.g. from a webhook, for all users (app(AnikeenId::class)->appCheckoutSessions()).
|
||||
* Nothing is requested until a method is called.
|
||||
*/
|
||||
class AppCheckoutSessions
|
||||
{
|
||||
public function __construct(protected Billable|Model|null $billable = null)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a checkout session with new subscriptions (their attributes, like
|
||||
* AppSubscriptions::create()) and/or existing drafts of the app (`['id' => …]`).
|
||||
*
|
||||
* @param array{
|
||||
* subscriptions: list<array{id: string}|array{
|
||||
* group?: null|string,
|
||||
* name: string,
|
||||
* description?: null|string,
|
||||
* unit: string,
|
||||
* price: float|string,
|
||||
* vat_rate?: float,
|
||||
* payload?: null|array,
|
||||
* ends_at?: null|string
|
||||
* }>,
|
||||
* return_url?: null|string,
|
||||
* metadata?: null|array,
|
||||
* idempotency_key?: null|string,
|
||||
* user_id?: string
|
||||
* } $attributes return_url must share scheme, host and port with one of the app's redirect
|
||||
* URIs; the user comes back to it with `status` and `checkout_session`. A
|
||||
* repeated idempotency_key returns the first session instead of a new one.
|
||||
*
|
||||
* @throws ApiException 422 validation (e.g. return_url), 409 "idempotency_key_conflict"
|
||||
*/
|
||||
public function create(array $attributes): AppCheckoutSession
|
||||
{
|
||||
if ($this->billable) {
|
||||
$attributes['user_id'] = (new AppSubscriptions($this->billable))->userId();
|
||||
} elseif (empty($attributes['user_id'])) {
|
||||
throw new InvalidArgumentException('A checkout session needs a user: use $user->appCheckoutSessions() or pass user_id.');
|
||||
}
|
||||
|
||||
return new AppCheckoutSession(AppRequest::send('POST', 'v1/client/checkout-sessions', $attributes)->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* A session of the app; null when it doesn't exist (or belongs to another user).
|
||||
*
|
||||
* @throws ApiException
|
||||
*/
|
||||
public function find(string $id): ?AppCheckoutSession
|
||||
{
|
||||
try {
|
||||
$session = new AppCheckoutSession(AppRequest::send('GET', sprintf('v1/client/checkout-sessions/%s', $id))->data);
|
||||
} catch (ApiException $e) {
|
||||
if ($e->isNotFound()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return !$this->billable || $session->user_id === (new AppSubscriptions($this->billable))->userId() ? $session : null;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ use JsonSerializable;
|
||||
* @property int $failed_renewal_retries
|
||||
* @property ?array $payload
|
||||
* @property ?string $idempotency_key
|
||||
* @property ?string $checkout_session_id the checkout session the user confirms it in, if any
|
||||
*/
|
||||
class AppSubscription implements JsonSerializable
|
||||
{
|
||||
@@ -47,7 +48,9 @@ class AppSubscription implements JsonSerializable
|
||||
}
|
||||
|
||||
/**
|
||||
* Change a subscription that wasn't checked out yet.
|
||||
* Change a subscription (send only what changes). Before checkout everything can change;
|
||||
* afterwards only the description, which names what the customer pays for and is shown
|
||||
* on their bills, e.g. `$subscription->update(['description' => 'web-01.example.com'])`.
|
||||
*
|
||||
* @throws ApiException
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Anikeen\Id\Resources;
|
||||
|
||||
use Anikeen\Id\Concerns\HasBillable;
|
||||
use Anikeen\Id\Concerns\IsCheckoutSession;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* A checkout session created with the user's access token, see CheckoutSessions.
|
||||
*
|
||||
* @property string $id
|
||||
* @property string $user_id
|
||||
* @property ?string $oauth_client_id
|
||||
* @property string $status open, completed, cancelled or expired
|
||||
* @property ?string $return_url
|
||||
* @property ?object $metadata
|
||||
* @property ?string $confirmation_url
|
||||
* @property list<string> $subscription_ids
|
||||
* @property list<object> $subscriptions
|
||||
* @property ?string $expires_at
|
||||
* @property ?string $completed_at
|
||||
* @property ?string $cancelled_at
|
||||
* @property ?string $created_at
|
||||
*/
|
||||
class CheckoutSession extends BaseResource
|
||||
{
|
||||
use HasBillable;
|
||||
use IsCheckoutSession;
|
||||
|
||||
public const STATUS_OPEN = 'open';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
|
||||
/**
|
||||
* Withdraw an open session: it and its drafts are cancelled.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function cancel(): self
|
||||
{
|
||||
return (new self(fn () => $this->billable->anikeenId()
|
||||
->request('POST', sprintf('v1/checkout-sessions/%s/cancel', $this->id))))
|
||||
->setBillable($this->billable);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function refresh(): self
|
||||
{
|
||||
return (new self(fn () => $this->billable->anikeenId()
|
||||
->request('GET', sprintf('v1/checkout-sessions/%s', $this->id))))
|
||||
->setBillable($this->billable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Anikeen\Id\Resources;
|
||||
|
||||
use Anikeen\Id\Concerns\HasBillable;
|
||||
use Anikeen\Id\Contracts\Billable;
|
||||
use Anikeen\Id\Exceptions\ResourceException;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Checkout sessions of the signed-in user, with the user's access token (any app): several
|
||||
* subscriptions the user confirms together on Anikeen ID. Nothing is requested until a method is
|
||||
* called.
|
||||
*/
|
||||
class CheckoutSessions
|
||||
{
|
||||
use HasBillable;
|
||||
|
||||
public function __construct(Billable|Model $billable)
|
||||
{
|
||||
$this->billable = $billable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a checkout session with new subscriptions and/or existing drafts of the app
|
||||
* (`['id' => …]`); see AppCheckoutSessions::create() for the attributes (no user_id here,
|
||||
* vat_rate only for trusted apps).
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function create(array $attributes): CheckoutSession
|
||||
{
|
||||
return (new CheckoutSession(fn () => $this->billable->anikeenId()
|
||||
->request('POST', 'v1/checkout-sessions', $attributes)))
|
||||
->setBillable($this->billable);
|
||||
}
|
||||
|
||||
/**
|
||||
* A session the app created for the user; null when it doesn't exist.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function find(string $id): ?CheckoutSession
|
||||
{
|
||||
try {
|
||||
return (new CheckoutSession(fn () => $this->billable->anikeenId()
|
||||
->request('GET', sprintf('v1/checkout-sessions/%s', $id))))
|
||||
->setBillable($this->billable);
|
||||
} catch (ResourceException $e) {
|
||||
if ($e->getCode() === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,19 @@ class Order extends BaseResource
|
||||
->request('DELETE', sprintf('v1/orders/%s', $this->id))->success();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliveries of this order's goods; `create()` ships them (see Shipments::create()).
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function shipments(): Shipments
|
||||
{
|
||||
return Shipments::builder(fn() => $this->billable->anikeenId()
|
||||
->request('GET', sprintf('v1/orders/%s/shipments', $this->id)))
|
||||
->setBillable($this->billable)
|
||||
->setParent($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get order items from given order.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Anikeen\Id\Resources;
|
||||
|
||||
use Anikeen\Id\Concerns\HasBillable;
|
||||
use Anikeen\Id\Concerns\HasParent;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property string $number delivery note number, e.g. LS-2026-0001
|
||||
* @property string $order_id
|
||||
* @property null|string $carrier
|
||||
* @property null|string $tracking_number
|
||||
* @property null|string $tracking_url
|
||||
* @property array $items order_item_id, name, description, units
|
||||
* @property object $shipping_address
|
||||
* @property null|string $note
|
||||
* @property string $shipped_at
|
||||
*/
|
||||
class Shipment extends BaseResource
|
||||
{
|
||||
use HasBillable;
|
||||
use HasParent;
|
||||
|
||||
/**
|
||||
* The delivery note as PDF (binary string).
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function deliveryNote(): string
|
||||
{
|
||||
$result = $this->billable->anikeenId()
|
||||
->request('GET', sprintf('v1/orders/%s/shipments/%s/delivery-note', $this->order_id, $this->id));
|
||||
|
||||
return (string) $result->response?->getBody();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Anikeen\Id\Resources;
|
||||
|
||||
use Anikeen\Id\Concerns\HasBillable;
|
||||
use Anikeen\Id\Concerns\HasParent;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Deliveries of an order's physical goods. Each one adds a delivery note to the customer's
|
||||
* order documents on Anikeen ID.
|
||||
*/
|
||||
class Shipments extends BaseCollection
|
||||
{
|
||||
use HasBillable;
|
||||
use HasParent;
|
||||
|
||||
/**
|
||||
* Ship goods of a paid order. Only the app that created the order (or a trusted app) can.
|
||||
*
|
||||
* Without `items`, everything physical that hasn't been shipped yet is shipped. Items and
|
||||
* the shipping address are stored as they are now; the user sees them on the delivery note.
|
||||
*
|
||||
* @param array{
|
||||
* carrier?: string,
|
||||
* tracking_number?: string,
|
||||
* tracking_url?: string,
|
||||
* note?: string,
|
||||
* shipped_at?: string,
|
||||
* notify_customer?: bool,
|
||||
* items?: array<array{order_item_id: string, units?: float|int|string}>
|
||||
* } $attributes
|
||||
* - carrier: e.g. "DHL"
|
||||
* - tracking_url: http(s) link to track the parcel, shown to the user
|
||||
* - shipped_at: when it was sent (default: now, not in the future)
|
||||
* - notify_customer: Anikeen ID mails the user with the delivery note (default true);
|
||||
* false if your shop sends its own mail
|
||||
* - items: physical items of the order and how many of them (default: all left)
|
||||
*
|
||||
* Errors (ResourceException with the HTTP status): 409 when the order isn't paid or nothing
|
||||
* is left to ship, 422 for items that aren't physical items of the order or exceed what's left.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function create(array $attributes = []): Shipment
|
||||
{
|
||||
return (new Shipment(fn() => $this->billable->anikeenId()
|
||||
->request('POST', sprintf('v1/orders/%s/shipments', $this->parent->id), $attributes)))
|
||||
->setBillable($this->billable)
|
||||
->setParent($this->parent);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function find(string $id): ?Shipment
|
||||
{
|
||||
return (new Shipment(fn() => $this->billable->anikeenId()
|
||||
->request('GET', sprintf('v1/orders/%s/shipments/%s', $this->parent->id, $id))))
|
||||
->setBillable($this->billable)
|
||||
->setParent($this->parent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Anikeen\Id\Webhooks;
|
||||
|
||||
/**
|
||||
* The `type` of webhook events Anikeen ID sends, e.g.
|
||||
* `match ($event['type']) { EventType::CHECKOUT_COMPLETED => …, … }`.
|
||||
*/
|
||||
final class EventType
|
||||
{
|
||||
public const ORDER_CREATED = 'order.created';
|
||||
public const ORDER_COMPLETED = 'order.completed';
|
||||
public const ORDER_PAYMENT_FAILED = 'order.payment_failed';
|
||||
public const ORDER_CANCELLED = 'order.cancelled';
|
||||
public const ORDER_SHIPPED = 'order.shipped';
|
||||
|
||||
public const SUBSCRIPTION_CREATED = 'subscription.created';
|
||||
public const SUBSCRIPTION_ACTIVATED = 'subscription.activated';
|
||||
public const SUBSCRIPTION_PAUSED = 'subscription.paused';
|
||||
public const SUBSCRIPTION_RESUMED = 'subscription.resumed';
|
||||
public const SUBSCRIPTION_CANCELLED = 'subscription.cancelled';
|
||||
public const SUBSCRIPTION_EXPIRED = 'subscription.expired';
|
||||
|
||||
/** The user confirmed a checkout session: all of its subscriptions are running (sent after their subscription.activated). */
|
||||
public const CHECKOUT_COMPLETED = 'checkout.completed';
|
||||
|
||||
/** The user or the app cancelled a checkout session; its drafts were cancelled. */
|
||||
public const CHECKOUT_CANCELLED = 'checkout.cancelled';
|
||||
|
||||
/** A checkout session was not confirmed within 24 hours; its drafts were cancelled. */
|
||||
public const CHECKOUT_EXPIRED = 'checkout.expired';
|
||||
|
||||
public const TEST = 'webhook.test';
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return array_values((new \ReflectionClass(self::class))->getConstants());
|
||||
}
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace Anikeen\Id\Tests;
|
||||
|
||||
use Anikeen\Id\AnikeenId;
|
||||
use Anikeen\Id\Billable;
|
||||
use Anikeen\Id\Exceptions\ApiException;
|
||||
use Anikeen\Id\Resources\AppCheckoutSession;
|
||||
use Anikeen\Id\Resources\AppSubscription;
|
||||
use Anikeen\Id\Resources\CheckoutSession;
|
||||
use Anikeen\Id\Webhooks\EventType;
|
||||
use Anikeen\Id\Webhooks\Webhook;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class CheckoutSessionsTest extends TestCase
|
||||
{
|
||||
private function owner(): Model
|
||||
{
|
||||
$owner = new class extends Model {
|
||||
use Billable;
|
||||
|
||||
protected $guarded = [];
|
||||
};
|
||||
$owner->anikeen_id = '01JUSER';
|
||||
$owner->anikeen_id_access_token = 'user-token';
|
||||
|
||||
return $owner;
|
||||
}
|
||||
|
||||
private function sessionData(array $overrides = []): array
|
||||
{
|
||||
return ['data' => [
|
||||
'object' => 'checkout_session',
|
||||
'id' => '01JCS',
|
||||
'user_id' => '01JUSER',
|
||||
'status' => 'open',
|
||||
'return_url' => 'https://console.test/builder/done',
|
||||
'metadata' => ['cart' => '42'],
|
||||
'confirmation_url' => 'https://id.anikeen.com/checkout/sessions/01JCS',
|
||||
'subscription_ids' => ['01JS1', '01JS2'],
|
||||
'subscriptions' => [
|
||||
['id' => '01JS1', 'user_id' => '01JUSER', 'name' => 'Cloud Server S', 'active' => false, 'payload' => ['product' => 'server-s']],
|
||||
['id' => '01JS2', 'user_id' => '01JUSER', 'name' => 'Backup', 'active' => false],
|
||||
],
|
||||
...$overrides,
|
||||
]];
|
||||
}
|
||||
|
||||
private function request(int $index): string
|
||||
{
|
||||
$request = $this->sent[$index]['request'];
|
||||
|
||||
return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization');
|
||||
}
|
||||
|
||||
public function test_a_trusted_app_opens_a_session_with_its_own_token(): void
|
||||
{
|
||||
$this->respond(201, $this->sessionData());
|
||||
|
||||
$session = $this->owner()->appCheckoutSessions()->create([
|
||||
'return_url' => 'https://console.test/builder/done',
|
||||
'idempotency_key' => 'cart:42',
|
||||
'subscriptions' => [
|
||||
['group' => 'team-1', 'name' => 'Cloud Server S', 'unit' => 'hour', 'price' => '0.0137', 'payload' => ['product' => 'server-s']],
|
||||
['id' => '01JS2'],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertSame('POST /api/v1/client/checkout-sessions Bearer app-token', $this->request(0));
|
||||
$this->assertSame('01JUSER', $this->sentJson(0)['user_id']);
|
||||
$this->assertSame([['group' => 'team-1', 'name' => 'Cloud Server S', 'unit' => 'hour', 'price' => '0.0137', 'payload' => ['product' => 'server-s']], ['id' => '01JS2']], $this->sentJson(0)['subscriptions']);
|
||||
|
||||
$this->assertInstanceOf(AppCheckoutSession::class, $session);
|
||||
$this->assertSame('https://id.anikeen.com/checkout/sessions/01JCS', $session->confirmationUrl());
|
||||
$this->assertTrue($session->isOpen());
|
||||
$this->assertSame(['01JS1', '01JS2'], $session->subscriptionIds());
|
||||
$this->assertContainsOnlyInstancesOf(AppSubscription::class, $session->subscriptions());
|
||||
$this->assertSame('server-s', $session->subscriptions()[0]->payload->product);
|
||||
$this->assertSame('42', $session->metadata->cart);
|
||||
}
|
||||
|
||||
public function test_the_app_finds_refreshes_and_cancels_sessions(): void
|
||||
{
|
||||
$this->respond(200, $this->sessionData(['status' => 'completed', 'confirmation_url' => null]));
|
||||
$this->respond(200, $this->sessionData(['user_id' => 'someone-else']));
|
||||
$this->respond(404, ['message' => 'Checkout session not found.', 'error' => 'checkout_session_not_found']);
|
||||
$this->respond(200, $this->sessionData());
|
||||
$this->respond(200, $this->sessionData(['status' => 'cancelled', 'confirmation_url' => null]));
|
||||
$this->respond(409, ['message' => 'The checkout session is cancelled.', 'error' => 'checkout_session_cancelled']);
|
||||
|
||||
$sessions = $this->owner()->appCheckoutSessions();
|
||||
|
||||
$completed = $sessions->find('01JCS');
|
||||
$this->assertTrue($completed->isCompleted());
|
||||
$this->assertNull($completed->confirmationUrl());
|
||||
$this->assertNull($sessions->find('01JCS'), 'another user\'s session');
|
||||
$this->assertNull($sessions->find('unknown'));
|
||||
|
||||
$session = app(AnikeenId::class)->appCheckoutSessions()->find('01JCS'); // e.g. from a webhook
|
||||
$session->cancel();
|
||||
$this->assertTrue($session->isCancelled());
|
||||
$this->assertSame('POST /api/v1/client/checkout-sessions/01JCS/cancel Bearer app-token', $this->request(4));
|
||||
|
||||
try {
|
||||
$session->cancel();
|
||||
$this->fail('Expected an ApiException.');
|
||||
} catch (ApiException $e) {
|
||||
$this->assertTrue($e->isConflict());
|
||||
$this->assertSame('checkout_session_cancelled', $e->error);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_without_a_billable_the_user_id_is_required(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
app(AnikeenId::class)->appCheckoutSessions()->create(['subscriptions' => [['id' => '01JS1']]]);
|
||||
}
|
||||
|
||||
public function test_any_app_opens_a_session_with_the_users_token(): void
|
||||
{
|
||||
$this->respond(201, $this->sessionData());
|
||||
$this->respond(200, $this->sessionData(['status' => 'expired', 'confirmation_url' => null]));
|
||||
$this->respond(404, ['message' => 'Checkout session not found.', 'error' => 'checkout_session_not_found']);
|
||||
|
||||
$user = $this->owner();
|
||||
$session = $user->checkoutSessions()->create(['subscriptions' => [['name' => 'Pro', 'unit' => 'month', 'price' => 4.99]]]);
|
||||
|
||||
$this->assertSame('POST /api/v1/checkout-sessions Bearer user-token', $this->request(0));
|
||||
$this->assertArrayNotHasKey('user_id', $this->sentJson(0));
|
||||
$this->assertInstanceOf(CheckoutSession::class, $session);
|
||||
$this->assertSame('https://id.anikeen.com/checkout/sessions/01JCS', $session->confirmationUrl());
|
||||
$this->assertSame(['01JS1', '01JS2'], $session->subscriptionIds());
|
||||
|
||||
$expired = $user->checkoutSessions()->find('01JCS');
|
||||
$this->assertTrue($expired->isCancelled());
|
||||
$this->assertSame(CheckoutSession::STATUS_EXPIRED, $expired->status);
|
||||
$this->assertNull($user->checkoutSessions()->find('unknown'));
|
||||
}
|
||||
|
||||
public function test_nothing_is_requested_until_a_method_is_called(): void
|
||||
{
|
||||
$this->owner()->checkoutSessions();
|
||||
$this->owner()->appCheckoutSessions();
|
||||
|
||||
$this->assertCount(0, $this->sent);
|
||||
}
|
||||
|
||||
public function test_checkout_webhooks_carry_the_session(): void
|
||||
{
|
||||
$payload = json_encode(['id' => '01JEV', 'type' => 'checkout.completed', 'created' => time(), 'data' => ['object' => $this->sessionData(['status' => 'completed'])['data']]]);
|
||||
$timestamp = time();
|
||||
$signature = 't=' . $timestamp . ',v1=' . hash_hmac('sha256', "{$timestamp}.{$payload}", 'whsec_test');
|
||||
|
||||
$event = Webhook::constructEvent($payload, $signature, 'whsec_test');
|
||||
|
||||
$this->assertSame(EventType::CHECKOUT_COMPLETED, $event['type']);
|
||||
$this->assertSame(['01JS1', '01JS2'], $event['data']['object']['subscription_ids']);
|
||||
$this->assertContains('checkout.expired', EventType::all());
|
||||
$this->assertContains('subscription.activated', EventType::all());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Anikeen\Id\Tests;
|
||||
|
||||
use Anikeen\Id\Billable;
|
||||
use Anikeen\Id\Exceptions\ResourceException;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ShipmentsTest extends TestCase
|
||||
{
|
||||
private function owner(): Model
|
||||
{
|
||||
$owner = new class extends Model {
|
||||
use Billable;
|
||||
|
||||
protected $guarded = [];
|
||||
};
|
||||
$owner->anikeen_id = '01JUSER';
|
||||
$owner->anikeen_id_access_token = 'user-token';
|
||||
|
||||
return $owner;
|
||||
}
|
||||
|
||||
private function order(): \Anikeen\Id\Resources\Order
|
||||
{
|
||||
$this->respond(200, ['data' => [], 'meta' => ['current_page' => 1, 'last_page' => 1, 'total' => 0], 'links' => ['next' => null]]);
|
||||
$this->respond(200, ['data' => ['id' => 'ord1', 'status' => 'completed']]);
|
||||
|
||||
return $this->owner()->orders()->find('ord1');
|
||||
}
|
||||
|
||||
private function sentCall(int $index): string
|
||||
{
|
||||
$request = $this->sent[$index]['request'];
|
||||
|
||||
return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization');
|
||||
}
|
||||
|
||||
public function test_an_order_is_shipped_with_carrier_tracking_and_items(): void
|
||||
{
|
||||
$order = $this->order();
|
||||
$this->respond(200, ['data' => []]);
|
||||
$this->respond(201, ['data' => ['id' => 'shp1', 'number' => 'LS-2026-0001', 'order_id' => 'ord1', 'carrier' => 'DHL']]);
|
||||
|
||||
$shipment = $order->shipments()->create([
|
||||
'carrier' => 'DHL',
|
||||
'tracking_number' => '0034',
|
||||
'items' => [['order_item_id' => 'item1', 'units' => 1]],
|
||||
]);
|
||||
|
||||
$this->assertSame('LS-2026-0001', $shipment->number);
|
||||
$this->assertSame('GET /api/v1/orders/ord1/shipments Bearer user-token', $this->sentCall(2));
|
||||
$this->assertSame('POST /api/v1/orders/ord1/shipments Bearer user-token', $this->sentCall(3));
|
||||
$this->assertSame(['carrier' => 'DHL', 'tracking_number' => '0034', 'items' => [['order_item_id' => 'item1', 'units' => 1]]], $this->sentJson(3));
|
||||
}
|
||||
|
||||
public function test_shipments_are_listed_and_the_delivery_note_is_downloaded(): void
|
||||
{
|
||||
$order = $this->order();
|
||||
$this->respond(200, ['data' => [['id' => 'shp1', 'number' => 'LS-2026-0001']]]);
|
||||
$this->respond(200, ['data' => ['id' => 'shp1', 'number' => 'LS-2026-0001', 'order_id' => 'ord1']]);
|
||||
$this->http->append(new Response(200, ['Content-Type' => 'application/pdf'], '%PDF-1.7 test'));
|
||||
|
||||
$shipments = $order->shipments();
|
||||
$this->assertSame('LS-2026-0001', $shipments->toArray()[0]->number);
|
||||
|
||||
$pdf = $shipments->find('shp1')->deliveryNote();
|
||||
|
||||
$this->assertSame('%PDF-1.7 test', $pdf);
|
||||
$this->assertSame('GET /api/v1/orders/ord1/shipments/shp1/delivery-note Bearer user-token', $this->sentCall(4));
|
||||
}
|
||||
|
||||
public function test_refusals_keep_their_status(): void
|
||||
{
|
||||
$order = $this->order();
|
||||
$this->respond(200, ['data' => []]);
|
||||
$this->respond(409, ['message' => 'Only paid orders can be shipped.', 'error' => 'order_not_paid']);
|
||||
|
||||
try {
|
||||
$order->shipments()->create();
|
||||
$this->fail('expected a ResourceException');
|
||||
} catch (ResourceException $e) {
|
||||
$this->assertSame(409, $e->getCode());
|
||||
$this->assertStringContainsString('Only paid orders can be shipped', $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user