mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-02 13:49:30 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a568a5354 | ||
|
|
5349ee81f6
|
||
|
|
60b4b80f17 | ||
|
|
1f645b50bc
|
||
|
|
9731dd36ca | ||
|
|
be59602b30
|
||
|
|
fd96e559af
|
No files matched your search
@@ -180,6 +180,42 @@ instead of trusting the query string. Subscriptions work the same way
|
|||||||
(`$user->subscriptions()->create([...])->confirmationUrl(...)`, `&subscription=…`).
|
(`$user->subscriptions()->create([...])->confirmationUrl(...)`, `&subscription=…`).
|
||||||
Unconfirmed bookings are cancelled after 24 hours.
|
Unconfirmed bookings are cancelled after 24 hours.
|
||||||
|
|
||||||
|
Physical goods need a shipping address (`first_name`, `last_name`, `address`, `postal_code`,
|
||||||
|
`city`, `country_iso`). The user can change both addresses on the confirmation page, so read the
|
||||||
|
shipping address from the order (or the `order.completed` webhook) before you ship. An item's
|
||||||
|
`image_url` is copied to Anikeen ID and stays visible on the order and invoice:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$order = $user->orders()->create([
|
||||||
|
'billing_address_id' => $addressId,
|
||||||
|
'shipping_address' => ['first_name' => 'Ada', 'last_name' => 'Lovelace', 'address' => 'Main St', 'house_number' => '1', 'postal_code' => '10115', 'city' => 'Berlin', 'country_iso' => 'DE'],
|
||||||
|
'items' => [
|
||||||
|
['type' => 'physical', 'name' => 'T-shirt', 'unit' => 'piece', 'units' => 1, 'gross_price' => '24.90', 'image_url' => 'https://shop.test/img/shirt.png'],
|
||||||
|
['type' => 'physical-reduced', 'name' => 'Artbook', 'unit' => 'piece', 'units' => 1, 'gross_price' => '19.95'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
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) 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`
|
`checkout()` on such a draft throws `ConfirmationRequiredException`; its `confirmationUrl`
|
||||||
points to the same page. Trusted apps keep checking out directly.
|
points to the same page. Trusted apps keep checking out directly.
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,21 @@ trait Billable
|
|||||||
use ManagesTransactions;
|
use ManagesTransactions;
|
||||||
use ManagesUserSshKeys;
|
use ManagesUserSshKeys;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* API results cached per instance. Declared, so Eloquent models don't take them for
|
||||||
|
* attributes (an undeclared property goes through Model::__set and save() then fails
|
||||||
|
* with "no such column").
|
||||||
|
*/
|
||||||
|
protected mixed $addressesCache = null;
|
||||||
|
protected mixed $countriesCache = null;
|
||||||
|
protected mixed $defaultPaymentMethodCache = null;
|
||||||
|
protected mixed $invoicesCache = null;
|
||||||
|
protected mixed $ordersCache = null;
|
||||||
|
protected mixed $paymentMethodsCache = null;
|
||||||
|
protected mixed $subscriptionsCache = null;
|
||||||
|
protected mixed $transactionsCache = null;
|
||||||
|
protected mixed $userDataCache = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the currently authenticated user.
|
* Get the currently authenticated user.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ trait ManagesUserBilling
|
|||||||
* billing_address?: array,
|
* billing_address?: array,
|
||||||
* shipping_address_id?: string,
|
* shipping_address_id?: string,
|
||||||
* shipping_address?: array,
|
* shipping_address?: array,
|
||||||
* items: array<array{type: string, name: string, description?: string, price?: float|string, gross_price?: float|string, unit: string, units: int}>,
|
* items: array<array{type: string, name: string, description?: string, image_url?: string, price?: float|string, gross_price?: float|string, unit: string, units: int}>,
|
||||||
* checkout?: bool,
|
* checkout?: bool,
|
||||||
* transaction_id?: string,
|
* transaction_id?: string,
|
||||||
* transaction?: array,
|
* transaction?: array,
|
||||||
|
|||||||
@@ -47,7 +47,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
|
* @throws ApiException
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -44,11 +44,11 @@ class Order extends BaseResource
|
|||||||
* company_name: null|string,
|
* company_name: null|string,
|
||||||
* first_name: string,
|
* first_name: string,
|
||||||
* last_name: string,
|
* last_name: string,
|
||||||
* address: null|string,
|
* address: string,
|
||||||
* address_2: string,
|
* address_2: null|string,
|
||||||
* house_number: null|string,
|
* house_number: null|string,
|
||||||
* city: string,
|
* city: string,
|
||||||
* state: string,
|
* state: null|string,
|
||||||
* postal_code: string,
|
* postal_code: string,
|
||||||
* country_iso: string,
|
* country_iso: string,
|
||||||
* phone_number: null|string,
|
* phone_number: null|string,
|
||||||
@@ -56,7 +56,7 @@ class Order extends BaseResource
|
|||||||
* }
|
* }
|
||||||
* } $attributes The order data:
|
* } $attributes The order data:
|
||||||
* - billing_address: Billing address (ISO 3166-1 alpha-2 country code)
|
* - billing_address: Billing address (ISO 3166-1 alpha-2 country code)
|
||||||
* - shipping_address: Shipping address (first name, last name, ISO 3166-1 alpha-2 country code)
|
* - shipping_address: Shipping address for physical items (first name, last name, address, postal code, city, ISO 3166-1 alpha-2 country code)
|
||||||
* @throws Throwable
|
* @throws Throwable
|
||||||
*/
|
*/
|
||||||
public function update(array $attributes = []): self
|
public function update(array $attributes = []): self
|
||||||
@@ -101,6 +101,19 @@ class Order extends BaseResource
|
|||||||
->request('DELETE', sprintf('v1/orders/%s', $this->id))->success();
|
->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.
|
* Get order items from given order.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -38,11 +38,11 @@ class Orders extends BaseCollection
|
|||||||
* company_name: null|string,
|
* company_name: null|string,
|
||||||
* first_name: string,
|
* first_name: string,
|
||||||
* last_name: string,
|
* last_name: string,
|
||||||
* address: null|string,
|
* address: string,
|
||||||
* address_2: string,
|
* address_2: null|string,
|
||||||
* house_number: null|string,
|
* house_number: null|string,
|
||||||
* city: string,
|
* city: string,
|
||||||
* state: string,
|
* state: null|string,
|
||||||
* postal_code: string,
|
* postal_code: string,
|
||||||
* country_iso: string,
|
* country_iso: string,
|
||||||
* phone_number: null|string,
|
* phone_number: null|string,
|
||||||
@@ -51,15 +51,22 @@ class Orders extends BaseCollection
|
|||||||
* items: array<array{
|
* items: array<array{
|
||||||
* type: string,
|
* type: string,
|
||||||
* name: string,
|
* name: string,
|
||||||
* description: string,
|
* description?: null|string,
|
||||||
* price: float|int,
|
* image_url?: null|string,
|
||||||
|
* price?: float|int|string,
|
||||||
|
* gross_price?: float|int|string,
|
||||||
* unit: string,
|
* unit: string,
|
||||||
* units: int
|
* units: int
|
||||||
* }>
|
* }>
|
||||||
* } $attributes The order data:
|
* } $attributes The order data:
|
||||||
* - billing_address: Billing address (ISO 3166-1 alpha-2 country code)
|
* - billing_address: Billing address (ISO 3166-1 alpha-2 country code)
|
||||||
* - shipping_address: Shipping address (first name, last name, ISO 3166-1 alpha-2 country code)
|
* - shipping_address: Shipping address for physical items (first name, last name, address, postal code, city, ISO 3166-1 alpha-2 country code)
|
||||||
* - items: Array of order items (each with type, name, price, unit, units, and quantity)
|
* - items: Array of order items:
|
||||||
|
* - type: digital, physical or physical-reduced (VAT category)
|
||||||
|
* - price/gross_price: net or gross unit price, one of both
|
||||||
|
* - unit: hour, day, week, month, year, onetime or piece
|
||||||
|
* - image_url: http(s) URL of a product image; Anikeen ID copies it, so it stays
|
||||||
|
* visible on the order and invoice when the shop removes it later
|
||||||
* @throws Throwable
|
* @throws Throwable
|
||||||
*/
|
*/
|
||||||
public function create(array $attributes = []): Order
|
public function create(array $attributes = []): 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,60 @@
|
|||||||
|
<?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,
|
||||||
|
* 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)
|
||||||
|
* - 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,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Anikeen\Id\Tests;
|
||||||
|
|
||||||
|
use Anikeen\Id\Billable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class BillableModelTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_cached_api_results_are_not_model_attributes(): void
|
||||||
|
{
|
||||||
|
$user = new class extends Model {
|
||||||
|
use Billable;
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
public function cacheAll(): void
|
||||||
|
{
|
||||||
|
foreach (['addresses', 'countries', 'defaultPaymentMethod', 'invoices', 'orders', 'paymentMethods', 'subscriptions', 'transactions', 'userData'] as $name) {
|
||||||
|
$this->{$name.'Cache'} = 'cached';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$user->email = 'ada@example.com';
|
||||||
|
|
||||||
|
$user->cacheAll();
|
||||||
|
|
||||||
|
// save() would otherwise write them as columns ("no such column: ordersCache")
|
||||||
|
$this->assertSame(['email' => 'ada@example.com'], $user->getAttributes());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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