Tie Parcel2Go shipping to pending Etsy orders
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 26s

This commit is contained in:
Hickmeister
2026-10-11 17:11:43 +01:00
parent 55c987ef47
commit 1c80811713
8 changed files with 199 additions and 71 deletions
+3 -3
View File
@@ -47,11 +47,11 @@ class DashboardController extends Controller
if ($isAdmin && filled($shippingConfig['client_id'] ?? null) && filled($shippingConfig['client_secret'] ?? null) && filled($shippingConfig['auth_url'] ?? null)) { if ($isAdmin && filled($shippingConfig['client_id'] ?? null) && filled($shippingConfig['client_secret'] ?? null) && filled($shippingConfig['auth_url'] ?? null)) {
try { try {
$parcel2Go->verifyCredentials(); $parcel2Go->verifyCredentials();
if ($parcel2Go->hasScope('my-profile')) { try {
$shipments = array_slice($parcel2Go->fetch('me/orders/detail')['Orders'] ?? [], 0, 4); $shipments = array_slice($parcel2Go->fetch('me/orders/detail')['Orders'] ?? [], 0, 4);
$shippingStatus = 'Live account'; $shippingStatus = 'Live account';
} else { } catch (Throwable $exception) {
$shippingStatus = 'Quotes available'; $shippingStatus = 'History access needed';
} }
} catch (Throwable $exception) { } catch (Throwable $exception) {
report($exception); report($exception);
@@ -36,6 +36,7 @@ class EtsySettingsController extends Controller
'auth_url' => $parcel2go['auth_url'] ?? '', 'auth_url' => $parcel2go['auth_url'] ?? '',
'base_url' => $parcel2go['base_url'] ?? '', 'base_url' => $parcel2go['base_url'] ?? '',
'scope' => $parcel2go['scope'] ?? 'public-api', 'scope' => $parcel2go['scope'] ?? 'public-api',
'collection_postcode' => $parcel2go['collection_postcode'] ?? '',
], ],
'bambuddy' => [ 'bambuddy' => [
'stored' => in_array('bambuddy', $storedIntegrations, true), 'stored' => in_array('bambuddy', $storedIntegrations, true),
@@ -21,6 +21,7 @@ class IntegrationSettingsController extends Controller
'auth_url' => ['nullable', 'url:https', 'max:2048'], 'auth_url' => ['nullable', 'url:https', 'max:2048'],
'base_url' => ['nullable', 'url:https', 'max:2048'], 'base_url' => ['nullable', 'url:https', 'max:2048'],
'scope' => ['nullable', 'string', 'in:public-api,public-api my-profile,public-api payment,public-api payment my-profile'], 'scope' => ['nullable', 'string', 'in:public-api,public-api my-profile,public-api payment,public-api payment my-profile'],
'collection_postcode' => ['nullable', 'string', 'max:12'],
], ],
default => [ default => [
'url' => ['nullable', 'url:https', 'max:2048'], 'url' => ['nullable', 'url:https', 'max:2048'],
+89 -43
View File
@@ -2,7 +2,11 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\EtsyConnection;
use App\Services\Etsy;
use App\Services\IntegrationSettings;
use App\Services\Parcel2Go; use App\Services\Parcel2Go;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
@@ -11,82 +15,105 @@ use Throwable;
class ShippingController extends Controller class ShippingController extends Controller
{ {
public function index(Request $request, Parcel2Go $parcel2Go): View public function index(Request $request, Parcel2Go $parcel2Go, Etsy $etsy, IntegrationSettings $settings): View
{ {
abort_unless($request->user()->role === 'admin', 403); abort_unless($request->user()->role === 'admin', 403);
$pendingOrders = [];
$etsyNotice = null;
try {
$pendingOrders = $this->pendingOrders($etsy);
if ($pendingOrders === [] && (! $etsy->configured() || ! EtsyConnection::query()->exists())) {
$etsyNotice = 'Connect your Etsy shop to see pending orders.';
}
} catch (Throwable $exception) {
report($exception);
$etsyNotice = 'Etsy pending orders are temporarily unavailable.';
}
$account = [];
$balance = [];
$orders = [];
$profileNotice = null;
$integrationError = null;
$historyAvailable = false;
try { try {
$parcel2Go->verifyCredentials(); $parcel2Go->verifyCredentials();
$account = []; try {
$balance = []; $orders = $parcel2Go->fetch('me/orders/detail');
$orders = []; $historyAvailable = true;
$profileAvailable = $parcel2Go->hasScope('my-profile'); } catch (Throwable $exception) {
$profileNotice = null; $profileNotice = $exception instanceof RequestException && in_array($exception->response->status(), [401, 403], true)
? 'Parcel2Go denied shipment history for this client. Request my-profile access from Parcel2Go API support.'
if ($profileAvailable) { : 'Parcel2Go shipment history is temporarily unavailable.';
}
if ($parcel2Go->hasScope('my-profile')) {
try { try {
$account = $parcel2Go->fetch('me/detail'); $account = $parcel2Go->fetch('me/detail');
$balance = $parcel2Go->fetch('me/prepay/balance'); $balance = $parcel2Go->fetch('me/prepay/balance');
$orders = $parcel2Go->fetch('me/orders/detail');
} catch (Throwable $exception) { } catch (Throwable $exception) {
report($exception); report($exception);
$profileNotice = 'Parcel2Go connected, but account history is unavailable. Check that this client has my-profile access.';
} }
} else {
$profileNotice = $parcel2Go->scopeWasReduced()
? 'Parcel2Go rejected the selected API access. Basic shipping access works, but account history and labels need my-profile permission for this client.'
: 'Parcel2Go connected for quotes and collections. Add my-profile access in Parcel2Go to show account history and labels.';
} }
return view('shipping', compact('account', 'balance', 'orders', 'profileAvailable', 'profileNotice') + [
'quotes' => $request->session()->get('shipping_quotes', []),
'collectionDates' => $request->session()->get('shipping_collection_dates', []),
'selectedService' => $request->session()->get('shipping_selected_service'),
'bookingResult' => $request->session()->get('shipping_booking_result'),
'bookingPending' => $request->session()->get('shipping_booking_pending', false),
]);
} catch (Throwable $exception) { } catch (Throwable $exception) {
report($exception); report($exception);
$integrationError = 'Parcel2Go is unavailable. Check API credentials and connectivity.';
return view('shipping', [
'account' => [], 'balance' => [], 'orders' => [],
'profileAvailable' => false,
'profileNotice' => null,
'quotes' => $request->session()->get('shipping_quotes', []),
'collectionDates' => $request->session()->get('shipping_collection_dates', []),
'selectedService' => $request->session()->get('shipping_selected_service'),
'bookingResult' => $request->session()->get('shipping_booking_result'),
'bookingPending' => $request->session()->get('shipping_booking_pending', false),
'integrationError' => 'Parcel2Go is unavailable. Check API credentials and connectivity.',
]);
} }
return view('shipping', [
'account' => $account, 'balance' => $balance, 'orders' => $orders,
'historyAvailable' => $historyAvailable, 'profileNotice' => $profileNotice,
'integrationError' => $integrationError, 'pendingOrders' => $pendingOrders,
'etsyNotice' => $etsyNotice,
'collectionPostcode' => $settings->for('parcel2go')['collection_postcode'] ?? '',
'selectedReceipt' => collect($pendingOrders)->first(fn (array $receipt): bool => (string) ($receipt['receipt_id'] ?? '') === (string) $request->session()->get('shipping_receipt_id')),
'quotes' => $request->session()->get('shipping_quotes', []),
'collectionDates' => $request->session()->get('shipping_collection_dates', []),
'selectedService' => $request->session()->get('shipping_selected_service'),
'bookingResult' => $request->session()->get('shipping_booking_result'),
'bookingPending' => $request->session()->get('shipping_booking_pending', false),
]);
} }
public function quote(Request $request, Parcel2Go $parcel2Go): RedirectResponse public function quote(Request $request, Parcel2Go $parcel2Go, Etsy $etsy, IntegrationSettings $settings): RedirectResponse
{ {
abort_unless($request->user()->role === 'admin', 403); abort_unless($request->user()->role === 'admin', 403);
if ($request->session()->get('shipping_booking_pending')) { if ($request->session()->get('shipping_booking_pending')) {
return redirect()->route('shipping')->withErrors(['collection' => 'A booking outcome is uncertain. Check your Parcel2Go account before trying again.']); return redirect()->route('shipping')->withErrors(['collection' => 'A booking outcome is uncertain. Check your Parcel2Go account before trying again.']);
} }
$details = $request->validate([ $details = $request->validate([
'collection_postcode' => ['required', 'string', 'max:12'], 'receipt_id' => ['required', 'integer', 'min:1'],
'delivery_postcode' => ['required', 'string', 'max:12'],
'weight' => ['required', 'numeric', 'gt:0', 'lte:30'], 'weight' => ['required', 'numeric', 'gt:0', 'lte:30'],
'length' => ['required', 'numeric', 'gt:0', 'lte:200'], 'length' => ['required', 'numeric', 'gt:0', 'lte:200'],
'width' => ['required', 'numeric', 'gt:0', 'lte:200'], 'width' => ['required', 'numeric', 'gt:0', 'lte:200'],
'height' => ['required', 'numeric', 'gt:0', 'lte:200'], 'height' => ['required', 'numeric', 'gt:0', 'lte:200'],
'value' => ['required', 'numeric', 'gte:0', 'lte:10000'],
]); ]);
try { try {
$receipt = collect($this->pendingOrders($etsy))->first(fn (array $order): bool => (string) ($order['receipt_id'] ?? '') === (string) $details['receipt_id']);
if (! $receipt) {
return redirect()->route('shipping')->withErrors(['quote' => 'This Etsy order is no longer pending. Refresh the order list.']);
}
$collectionPostcode = $settings->for('parcel2go')['collection_postcode'] ?? null;
if (blank($collectionPostcode) || blank($receipt['zip'] ?? null) || ($receipt['country_iso'] ?? 'GB') !== 'GB' || ($receipt['grandtotal']['currency_code'] ?? 'GBP') !== 'GBP') {
return redirect()->route('shipping')->withErrors(['quote' => 'A UK collection postcode and a UK Etsy delivery address priced in GBP are required.']);
}
$total = $receipt['grandtotal'] ?? [];
$value = (float) ($total['amount'] ?? 0) / max(1, (int) ($total['divisor'] ?? 1));
$details = array_merge($details, [
'collection_postcode' => $collectionPostcode,
'delivery_postcode' => $receipt['zip'],
'value' => min(10000, max(0, $value)),
]);
$quotes = $parcel2Go->quote($details)['Quotes'] ?? []; $quotes = $parcel2Go->quote($details)['Quotes'] ?? [];
$request->session()->put('shipping_quote', $details); $request->session()->put('shipping_quote', $details);
$request->session()->put('shipping_receipt_id', $receipt['receipt_id']);
$request->session()->put('shipping_quotes', $quotes); $request->session()->put('shipping_quotes', $quotes);
$request->session()->forget(['shipping_collection_dates', 'shipping_selected_service']); $request->session()->forget(['shipping_collection_dates', 'shipping_selected_service', 'shipping_sender_location']);
return redirect()->route('shipping')->with('status', count($quotes).' services available.'); return redirect()->route('shipping')->with('status', count($quotes).' services available.');
} catch (Throwable $exception) { } catch (Throwable $exception) {
report($exception); report($exception);
return redirect()->route('shipping')->withErrors(['quote' => 'Parcel2Go could not quote this parcel. Check the postcodes and parcel details.']); return redirect()->route('shipping')->withErrors(['quote' => 'Could not quote this Etsy order. Check its delivery address and parcel details.']);
} }
} }
@@ -118,7 +145,7 @@ class ShippingController extends Controller
} }
} }
public function bookCollection(Request $request, Parcel2Go $parcel2Go): RedirectResponse public function bookCollection(Request $request, Parcel2Go $parcel2Go, Etsy $etsy): RedirectResponse
{ {
abort_unless($request->user()->role === 'admin', 403); abort_unless($request->user()->role === 'admin', 403);
if ($request->session()->get('shipping_booking_pending')) { if ($request->session()->get('shipping_booking_pending')) {
@@ -152,13 +179,24 @@ class ShippingController extends Controller
return redirect()->route('shipping')->withErrors(['collection' => 'Choose an available collection date for the quoted address.']); return redirect()->route('shipping')->withErrors(['collection' => 'Choose an available collection date for the quoted address.']);
} }
try {
$receipt = collect($this->pendingOrders($etsy))->first(fn (array $order): bool => (string) ($order['receipt_id'] ?? '') === (string) $request->session()->get('shipping_receipt_id'));
if (! $receipt || ($receipt['zip'] ?? null) !== $details['delivery_postcode']) {
return redirect()->route('shipping')->withErrors(['collection' => 'The Etsy order or delivery postcode changed. Request a fresh quote.']);
}
} catch (Throwable $exception) {
report($exception);
return redirect()->route('shipping')->withErrors(['collection' => 'Could not verify the Etsy order before booking. Try again.']);
}
try { try {
$request->session()->put('shipping_booking_pending', true); $request->session()->put('shipping_booking_pending', true);
$result = $parcel2Go->bookCollection($details, $service, $booking); $result = $parcel2Go->bookCollection($details, $service, $booking);
if (empty($result['OrderId'])) { if (empty($result['OrderId'])) {
throw new \RuntimeException('Parcel2Go did not return an order reference.'); throw new \RuntimeException('Parcel2Go did not return an order reference.');
} }
$request->session()->forget(['shipping_quote', 'shipping_quotes', 'shipping_collection_dates', 'shipping_selected_service', 'shipping_sender_location', 'shipping_booking_pending']); $request->session()->forget(['shipping_quote', 'shipping_receipt_id', 'shipping_quotes', 'shipping_collection_dates', 'shipping_selected_service', 'shipping_sender_location', 'shipping_booking_pending']);
return redirect()->route('shipping')->with('shipping_booking_result', $result)->with('status', 'Collection order created. Complete payment with Parcel2Go.'); return redirect()->route('shipping')->with('shipping_booking_result', $result)->with('status', 'Collection order created. Complete payment with Parcel2Go.');
} catch (Throwable $exception) { } catch (Throwable $exception) {
@@ -172,7 +210,7 @@ class ShippingController extends Controller
{ {
abort_unless($request->user()->role === 'admin', 403); abort_unless($request->user()->role === 'admin', 403);
$request->validate(['checked_account' => ['accepted']]); $request->validate(['checked_account' => ['accepted']]);
$request->session()->forget(['shipping_quote', 'shipping_quotes', 'shipping_collection_dates', 'shipping_selected_service', 'shipping_sender_location', 'shipping_booking_pending']); $request->session()->forget(['shipping_quote', 'shipping_receipt_id', 'shipping_quotes', 'shipping_collection_dates', 'shipping_selected_service', 'shipping_sender_location', 'shipping_booking_pending']);
return redirect()->route('shipping')->with('status', 'Booking workflow reset. Start with a fresh quote.'); return redirect()->route('shipping')->with('status', 'Booking workflow reset. Start with a fresh quote.');
} }
@@ -192,4 +230,12 @@ class ShippingController extends Controller
'Cache-Control' => 'private, no-store', 'Cache-Control' => 'private, no-store',
]); ]);
} }
/** @return array<int, array<string, mixed>> */
private function pendingOrders(Etsy $etsy): array
{
$connection = EtsyConnection::first();
return $connection && $etsy->configured() ? ($etsy->receipts($connection)['results'] ?? []) : [];
}
} }
+2 -1
View File
@@ -55,6 +55,7 @@
<label class="grid gap-2 text-sm text-slate-300">Client secret<input name="client_secret" type="password" autocomplete="new-password" maxlength="255" placeholder="Leave blank to keep current value" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 placeholder:text-slate-500"></label> <label class="grid gap-2 text-sm text-slate-300">Client secret<input name="client_secret" type="password" autocomplete="new-password" maxlength="255" placeholder="Leave blank to keep current value" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 placeholder:text-slate-500"></label>
<label class="grid gap-2 text-sm text-slate-300">OAuth token URL<input name="auth_url" type="url" maxlength="2048" value="{{ old('auth_url', $integrations['parcel2go']['auth_url']) }}" placeholder="https://…" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label> <label class="grid gap-2 text-sm text-slate-300">OAuth token URL<input name="auth_url" type="url" maxlength="2048" value="{{ old('auth_url', $integrations['parcel2go']['auth_url']) }}" placeholder="https://…" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
<label class="grid gap-2 text-sm text-slate-300">API base URL<input name="base_url" type="url" maxlength="2048" value="{{ old('base_url', $integrations['parcel2go']['base_url']) }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label> <label class="grid gap-2 text-sm text-slate-300">API base URL<input name="base_url" type="url" maxlength="2048" value="{{ old('base_url', $integrations['parcel2go']['base_url']) }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
<label class="grid gap-2 text-sm text-slate-300">Collection postcode<input name="collection_postcode" maxlength="12" value="{{ old('collection_postcode', $integrations['parcel2go']['collection_postcode']) }}" placeholder="Used for Etsy order shipping quotes" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
<label class="grid gap-2 text-sm text-slate-300">API access <label class="grid gap-2 text-sm text-slate-300">API access
<select name="scope" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"> <select name="scope" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100">
@foreach(['public-api' => 'Shipping quotes and collections', 'public-api my-profile' => 'Shipping and account history', 'public-api payment' => 'Shipping and PrePay payments', 'public-api payment my-profile' => 'Shipping, account history and PrePay'] as $scope => $label) @foreach(['public-api' => 'Shipping quotes and collections', 'public-api my-profile' => 'Shipping and account history', 'public-api payment' => 'Shipping and PrePay payments', 'public-api payment my-profile' => 'Shipping, account history and PrePay'] as $scope => $label)
@@ -62,7 +63,7 @@
@endforeach @endforeach
</select> </select>
</label> </label>
<p class="text-xs text-slate-500">Choose account history only if Parcel2Go granted the <code>my-profile</code> scope to this client. An ungranted scope prevents sign in.</p> <p class="text-xs text-slate-500">To request account history, sign in to Parcel2Go My Account &rarr; Settings &rarr; API and check this client's permissions. If <code>my-profile</code> is unavailable, email <a class="text-brand hover:underline" href="mailto:apihelp@parcel2go.com">apihelp@parcel2go.com</a> with your account email and Client ID. Ask them to enable <code>my-profile</code> for your live client. Never email the Client secret. After confirmation, select “Shipping and account history” here.</p>
<button class="w-fit rounded-xl bg-brand px-5 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">Save Parcel2Go</button> <button class="w-fit rounded-xl bg-brand px-5 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">Save Parcel2Go</button>
</form> </form>
</section> </section>
+32 -14
View File
@@ -1,23 +1,41 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'Shipping') @section('title', 'Shipping')
@section('content') @section('content')
<div><p class="text-xs font-bold uppercase tracking-[.2em] text-brand">Dispatch</p><h2 class="mt-2 text-2xl font-bold">Parcel2Go</h2><p class="mt-1 text-slate-400">Compare services, arrange a collection and download labels.</p></div> <div><p class="text-xs font-bold uppercase tracking-[.2em] text-brand">Dispatch</p><h2 class="mt-2 text-2xl font-bold">Orders and shipping</h2><p class="mt-1 text-slate-400">Prepare pending Etsy orders and follow Parcel2Go shipments.</p></div>
@isset($integrationError)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">{{ $integrationError }}</div>@endisset @isset($integrationError)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">{{ $integrationError }}</div>@endisset
@if($profileNotice)<div role="status" class="rounded-xl border border-slate-700 bg-slate-900 p-4 text-sm text-slate-300">{{ $profileNotice }}</div>@endif @if($etsyNotice)<div role="status" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">{{ $etsyNotice }} <a href="{{ route('settings.index') }}" class="font-semibold underline">Settings</a></div>@endif
@if($profileNotice)<div role="status" class="rounded-xl border border-slate-700 bg-slate-900 p-4 text-sm text-slate-300">{{ $profileNotice }} See the access steps in <a href="{{ route('settings.index') }}" class="font-semibold text-brand underline">Settings</a>.</div>@endif
@if($bookingPending)<section role="alert" class="rounded-2xl border border-amber-500/40 bg-amber-500/10 p-6"><h3 class="font-semibold text-amber-200">Check Parcel2Go before retrying</h3><p class="mt-2 text-sm text-amber-100/80">The last booking request had no confirmed result. Check your account for the collection order, then reset this form.</p><form method="post" action="{{ route('shipping.reset-booking') }}" class="mt-4 flex flex-wrap items-center gap-3">@csrf<label class="text-sm"><input type="checkbox" name="checked_account" value="1" required> I checked the Parcel2Go account</label><button class="rounded-xl border border-amber-500/50 px-4 py-2 text-sm font-semibold text-amber-200">Reset booking form</button></form></section>@endif @if($bookingPending)<section role="alert" class="rounded-2xl border border-amber-500/40 bg-amber-500/10 p-6"><h3 class="font-semibold text-amber-200">Check Parcel2Go before retrying</h3><p class="mt-2 text-sm text-amber-100/80">The last booking request had no confirmed result. Check your account for the collection order, then reset this form.</p><form method="post" action="{{ route('shipping.reset-booking') }}" class="mt-4 flex flex-wrap items-center gap-3">@csrf<label class="text-sm"><input type="checkbox" name="checked_account" value="1" required> I checked the Parcel2Go account</label><button class="rounded-xl border border-amber-500/50 px-4 py-2 text-sm font-semibold text-amber-200">Reset booking form</button></form></section>@endif
@if($bookingResult) @if($bookingResult)
@php $paymentUrl = $bookingResult['Links']['payment'] ?? null; $paymentHost = is_string($paymentUrl) ? parse_url($paymentUrl, PHP_URL_HOST) : null; @endphp @php $paymentUrl = $bookingResult['Links']['payment'] ?? null; $paymentHost = is_string($paymentUrl) ? parse_url($paymentUrl, PHP_URL_HOST) : null; @endphp
<section class="rounded-2xl border border-brand/40 bg-brand/10 p-6"><h3 class="text-lg font-semibold">Collection order #{{ $bookingResult['OrderId'] ?? '—' }} created</h3><p class="mt-2 text-sm text-slate-300">Total: £{{ number_format((float) ($bookingResult['TotalPrice'] ?? 0), 2) }}. Payment and collection confirmation are completed with Parcel2Go.</p>@if($paymentHost && ($paymentHost === 'parcel2go.com' || str_ends_with($paymentHost, '.parcel2go.com')))<a href="{{ $paymentUrl }}" target="_blank" rel="noopener noreferrer" class="mt-4 inline-flex rounded-xl bg-brand px-5 py-3 font-semibold text-slate-950">Complete payment ↗</a>@endif</section> <section class="rounded-2xl border border-brand/40 bg-brand/10 p-6"><h3 class="text-lg font-semibold">Collection order #{{ $bookingResult['OrderId'] ?? '—' }} created</h3><p class="mt-2 text-sm text-slate-300">Total: £{{ number_format((float) ($bookingResult['TotalPrice'] ?? 0), 2) }}. Payment and collection confirmation are completed with Parcel2Go.</p>@if($paymentHost && ($paymentHost === 'parcel2go.com' || str_ends_with($paymentHost, '.parcel2go.com')))<a href="{{ $paymentUrl }}" target="_blank" rel="noopener noreferrer" class="mt-4 inline-flex rounded-xl bg-brand px-5 py-3 font-semibold text-slate-950">Complete payment ↗</a>@endif</section>
@endif @endif
@if($profileAvailable)<div class="grid gap-4 md:grid-cols-2"><div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">Account</p><p class="mt-4 text-xl font-semibold">{{ trim(($account['Forename'] ?? '').' '.($account['Surname'] ?? '')) ?: 'Unavailable' }}</p><p class="mt-2 text-sm text-slate-400">{{ $account['Email'] ?? '' }}</p></div><div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">Prepay balance</p><p class="mt-4 text-3xl font-bold">{{ isset($balance['Balance']) ? '£'.number_format((float) $balance['Balance'], 2) : 'Unavailable' }}</p></div></div>@endif @if($account || $balance)<div class="grid gap-4 md:grid-cols-2"><div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">Account</p><p class="mt-4 text-xl font-semibold">{{ trim(($account['Forename'] ?? '').' '.($account['Surname'] ?? '')) ?: 'Unavailable' }}</p><p class="mt-2 text-sm text-slate-400">{{ $account['Email'] ?? '' }}</p></div><div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">Prepay balance</p><p class="mt-4 text-3xl font-bold">{{ isset($balance['Balance']) ? '£'.number_format((float) $balance['Balance'], 2) : 'Unavailable' }}</p></div></div>@endif
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><div class="mb-5"><h3 class="text-lg font-semibold">1. Compare services</h3><p class="mt-1 text-sm text-slate-400">Enter a UK parcel. Dimensions are centimetres and weight is kilograms.</p></div><form method="post" action="{{ route('shipping.quote') }}" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">@csrf <section class="rounded-2xl border border-slate-800 bg-slate-900 p-6">
<label class="grid gap-2 text-sm">Collection postcode<input name="collection_postcode" value="{{ old('collection_postcode') }}" required maxlength="12" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"></label> <div class="flex flex-wrap items-start justify-between gap-3"><div><h3 class="text-lg font-semibold">Pending Etsy orders</h3><p class="mt-1 text-sm text-slate-400">Quotes use the Etsy delivery postcode and order value. Enter the packed parcel size and weight.</p></div><span class="rounded-full bg-brand/10 px-3 py-1 text-sm font-semibold text-brand">{{ count($pendingOrders) }} pending</span></div>
<label class="grid gap-2 text-sm">Delivery postcode<input name="delivery_postcode" value="{{ old('delivery_postcode') }}" required maxlength="12" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"></label> @if(blank($collectionPostcode))<p class="mt-4 rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">Set your collection postcode once in <a class="font-semibold underline" href="{{ route('settings.index') }}">Parcel2Go settings</a> to quote these orders.</p>@endif
@foreach([['weight','Weight (kg)','0.1'],['length','Length (cm)','1'],['width','Width (cm)','1'],['height','Height (cm)','1'],['value','Parcel value (£)','0.01']] as [$field,$label,$step])<label class="grid gap-2 text-sm">{{ $label }}<input type="number" name="{{ $field }}" value="{{ old($field) }}" min="0" step="{{ $step }}" required class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"></label>@endforeach <div class="mt-5 grid gap-4">
<div class="flex items-end"><button class="w-full rounded-xl bg-brand px-5 py-3 font-semibold text-slate-950">Compare services</button></div> @forelse($pendingOrders as $receipt)
</form></section> @php $canQuote = filled($collectionPostcode) && filled($receipt['zip'] ?? null) && ($receipt['country_iso'] ?? 'GB') === 'GB' && ($receipt['grandtotal']['currency_code'] ?? 'GBP') === 'GBP'; @endphp
@if(count($quotes))<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><h3 class="text-lg font-semibold">2. Choose a collection service</h3><div class="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">@foreach($quotes as $quote)@php $service = $quote['Service'] ?? []; @endphp<article class="rounded-xl border border-slate-700 bg-slate-950 p-4"><p class="font-semibold">{{ $service['Name'] ?? 'Courier service' }}</p><p class="mt-1 text-sm text-slate-400">{{ $service['CourierName'] ?? '' }} · {{ $service['CollectionType'] ?? 'Unknown' }}</p><p class="mt-3 text-2xl font-bold text-brand">{{ isset($quote['TotalPrice']) ? '£'.number_format((float) $quote['TotalPrice'], 2) : 'See details' }}</p>@if(($service['CollectionType'] ?? null) === 'Collection' && ! empty($service['Slug']))<form method="post" action="{{ route('shipping.collection-dates') }}" class="mt-4 grid gap-3">@csrf<input type="hidden" name="service" value="{{ $service['Slug'] }}"><label class="grid gap-1 text-xs text-slate-400">Collection street<input name="sender_street" value="{{ old('sender_street') }}" required class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white"></label><label class="grid gap-1 text-xs text-slate-400">Collection town<input name="sender_town" value="{{ old('sender_town') }}" required class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white"></label><button class="rounded-lg border border-brand/40 px-3 py-2 text-sm font-semibold text-brand">Check collection dates</button></form>@endif</article>@endforeach</div></section>@endif <article class="rounded-xl border border-slate-700 bg-slate-950 p-4">
@if(count($collectionDates) && $selectedService)<section class="rounded-2xl border border-brand/30 bg-slate-900 p-6"><h3 class="text-lg font-semibold">3. Arrange collection</h3><p class="mt-1 text-sm text-slate-400">Creating the order does not take payment. Parcel2Go will provide a payment link.</p><form method="post" action="{{ route('shipping.book-collection') }}" class="mt-5 grid gap-5">@csrf<label class="grid max-w-md gap-2 text-sm">Collection date<select name="collection_date" required class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"><option value="">Choose a date</option>@foreach($collectionDates as $date)<option value="{{ $date['CollectionDate'] ?? '' }}">{{ isset($date['CollectionDate']) ? \Illuminate\Support\Carbon::parse($date['CollectionDate'])->format('D d M Y') : 'Available date' }}{{ ! empty($date['Surcharge']) ? ' · £'.number_format((float) $date['Surcharge'], 2).' surcharge' : '' }}</option>@endforeach</select></label> <div class="flex flex-wrap items-start justify-between gap-3"><div><p class="font-semibold">Order #{{ $receipt['receipt_id'] ?? '—' }}</p><p class="mt-1 text-sm text-slate-300">{{ $receipt['name'] ?? 'Buyer' }} · {{ $receipt['city'] ?? 'City unavailable' }} · {{ $receipt['zip'] ?? 'Postcode unavailable' }}</p></div>@if(isset($receipt['grandtotal']['amount']))<p class="text-sm font-semibold text-brand">{{ $receipt['grandtotal']['currency_code'] ?? 'GBP' }} {{ number_format((float) $receipt['grandtotal']['amount'] / max(1, (int) ($receipt['grandtotal']['divisor'] ?? 1)), 2) }}</p>@endif</div>
<div class="grid gap-5 lg:grid-cols-2"><fieldset class="grid gap-3 rounded-xl border border-slate-700 p-4"><legend class="px-2 font-semibold">Sender and collection</legend>@foreach([['sender_name','Name'],['sender_email','Email'],['sender_phone','Phone'],['sender_property','Property'],['sender_street','Street'],['sender_town','Town']] as [$field,$label])<label class="grid gap-1 text-sm">{{ $label }}<input name="{{ $field }}" value="{{ old($field, session('shipping_sender_location.'.$field)) }}" required class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2"></label>@endforeach</fieldset><fieldset class="grid gap-3 rounded-xl border border-slate-700 p-4"><legend class="px-2 font-semibold">Recipient and delivery</legend>@foreach([['recipient_name','Name'],['recipient_email','Email'],['recipient_phone','Phone'],['recipient_property','Property'],['recipient_street','Street'],['recipient_town','Town']] as [$field,$label])<label class="grid gap-1 text-sm">{{ $label }}<input name="{{ $field }}" value="{{ old($field) }}" required class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2"></label>@endforeach</fieldset></div><label class="grid max-w-xl gap-2 text-sm">Parcel contents<input name="contents" value="{{ old('contents') }}" required maxlength="200" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"></label><button class="w-fit rounded-xl bg-brand px-5 py-3 font-semibold text-slate-950">Create unpaid order</button></form></section>@endif @if($canQuote)
<section class="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-900"><h3 class="p-6 text-lg font-semibold">Recent shipments and labels</h3><table class="w-full min-w-[750px] text-left text-sm"><thead class="border-y border-slate-800 text-slate-400"><tr><th class="p-4">Order</th><th class="p-4">Courier</th><th class="p-4">Service</th><th class="p-4">Status</th><th class="p-4">Recipient</th><th class="p-4">Label</th></tr></thead><tbody class="divide-y divide-slate-800">@forelse(($orders['Orders'] ?? []) as $order)<tr><td class="p-4">{{ $order['OrderLineId'] ?? '—' }}</td><td class="p-4">{{ $order['Courier'] ?? '—' }}</td><td class="p-4">{{ $order['Service'] ?? '—' }}</td><td class="p-4">{{ ($order['Tracking']['Delivered'] ?? false) ? 'Delivered' : 'In transit' }}</td><td class="p-4">{{ $order['DeliveryAddress']['ContactName'] ?? '—' }}</td><td class="p-4">@if(! empty($order['Hash']) && ! empty($order['OrderLineId']))<a class="font-semibold text-brand hover:underline" href="{{ route('shipping.label', $order['OrderLineId']) }}">Download PDF</a>@else<span class="text-slate-500">Unavailable</span>@endif</td></tr>@empty<tr><td colspan="6" class="p-6 text-slate-400">{{ $profileAvailable ? 'No shipments available.' : 'Account history requires my-profile access from Parcel2Go.' }}</td></tr>@endforelse</tbody></table></section> <form method="post" action="{{ route('shipping.quote') }}" class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">@csrf<input type="hidden" name="receipt_id" value="{{ $receipt['receipt_id'] }}">
@endsection @foreach([['weight','Weight (kg)','0.1'],['length','Length (cm)','1'],['width','Width (cm)','1'],['height','Height (cm)','1']] as [$field,$label,$step])<label class="grid gap-1 text-xs text-slate-400">{{ $label }}<input type="number" name="{{ $field }}" value="{{ old($field) }}" min="0.01" step="{{ $step }}" required class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white"></label>@endforeach
<div class="flex items-end"><button class="w-full rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-slate-950">Quote this order</button></div>
</form>
@else<p class="mt-3 text-sm text-amber-200">{{ blank($collectionPostcode) ? 'Add a collection postcode in Settings.' : 'A UK delivery postcode and GBP order value are needed from Etsy before this order can be quoted.' }}</p>@endif
</article>
@empty<p class="rounded-xl border border-slate-700 bg-slate-950 p-5 text-sm text-slate-400">No paid, unshipped Etsy orders to prepare.</p>@endforelse
</div>
</section>@if(count($quotes) && $selectedReceipt)<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><h3 class="text-lg font-semibold">Collection services for Etsy order #{{ $selectedReceipt['receipt_id'] }}</h3><div class="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">@foreach($quotes as $quote)@php $service = $quote['Service'] ?? []; @endphp<article class="rounded-xl border border-slate-700 bg-slate-950 p-4"><p class="font-semibold">{{ $service['Name'] ?? 'Courier service' }}</p><p class="mt-1 text-sm text-slate-400">{{ $service['CourierName'] ?? '' }} · {{ $service['CollectionType'] ?? 'Unknown' }}</p><p class="mt-3 text-2xl font-bold text-brand">{{ isset($quote['TotalPrice']) ? '£'.number_format((float) $quote['TotalPrice'], 2) : 'See details' }}</p>@if(($service['CollectionType'] ?? null) === 'Collection' && ! empty($service['Slug']))<form method="post" action="{{ route('shipping.collection-dates') }}" class="mt-4 grid gap-3">@csrf<input type="hidden" name="service" value="{{ $service['Slug'] }}"><label class="grid gap-1 text-xs text-slate-400">Collection street<input name="sender_street" value="{{ old('sender_street') }}" required class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white"></label><label class="grid gap-1 text-xs text-slate-400">Collection town<input name="sender_town" value="{{ old('sender_town') }}" required class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white"></label><button class="rounded-lg border border-brand/40 px-3 py-2 text-sm font-semibold text-brand">Check collection dates</button></form>@endif</article>@endforeach</div></section>@endif
@if(count($collectionDates) && $selectedService && $selectedReceipt)<section class="rounded-2xl border border-brand/30 bg-slate-900 p-6"><h3 class="text-lg font-semibold">Arrange collection for Etsy order #{{ $selectedReceipt['receipt_id'] }}</h3><p class="mt-1 text-sm text-slate-400">Creating the order does not take payment. Parcel2Go will provide a payment link.</p><form method="post" action="{{ route('shipping.book-collection') }}" class="mt-5 grid gap-5">@csrf<label class="grid max-w-md gap-2 text-sm">Collection date<select name="collection_date" required class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"><option value="">Choose a date</option>@foreach($collectionDates as $date)<option value="{{ $date['CollectionDate'] ?? '' }}">{{ isset($date['CollectionDate']) ? \Illuminate\Support\Carbon::parse($date['CollectionDate'])->format('D d M Y') : 'Available date' }}{{ ! empty($date['Surcharge']) ? ' · £'.number_format((float) $date['Surcharge'], 2).' surcharge' : '' }}</option>@endforeach</select></label>
<div class="grid gap-5 lg:grid-cols-2"><fieldset class="grid gap-3 rounded-xl border border-slate-700 p-4"><legend class="px-2 font-semibold">Sender and collection</legend>@foreach([['sender_name','Name'],['sender_email','Email'],['sender_phone','Phone'],['sender_property','Property'],['sender_street','Street'],['sender_town','Town']] as [$field,$label])<label class="grid gap-1 text-sm">{{ $label }}<input name="{{ $field }}" value="{{ old($field, session('shipping_sender_location.'.$field)) }}" required class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2"></label>@endforeach</fieldset><fieldset class="grid gap-3 rounded-xl border border-slate-700 p-4"><legend class="px-2 font-semibold">Recipient and delivery</legend>@foreach([['recipient_name','Name'],['recipient_email','Email'],['recipient_phone','Phone'],['recipient_property','Property'],['recipient_street','Street'],['recipient_town','Town']] as [$field,$label])<label class="grid gap-1 text-sm">{{ $label }}<input name="{{ $field }}" value="{{ old($field, ['recipient_name' => $selectedReceipt['name'] ?? '', 'recipient_email' => $selectedReceipt['buyer_email'] ?? '', 'recipient_property' => $selectedReceipt['first_line'] ?? '', 'recipient_street' => $selectedReceipt['second_line'] ?? '', 'recipient_town' => $selectedReceipt['city'] ?? ''][$field] ?? '') }}" required class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2"></label>@endforeach</fieldset></div><label class="grid max-w-xl gap-2 text-sm">Parcel contents<input name="contents" value="{{ old('contents') }}" required maxlength="200" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"></label><button class="w-fit rounded-xl bg-brand px-5 py-3 font-semibold text-slate-950">Create unpaid order</button></form></section>@endif
<section class="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-900">
<div class="p-6"><h3 class="text-lg font-semibold">Existing shipments</h3><p class="mt-1 text-sm text-slate-400">Current Parcel2Go progress and labels from your account history.</p></div>
<table class="w-full min-w-[800px] text-left text-sm"><thead class="border-y border-slate-800 text-slate-400"><tr><th class="p-4">Order</th><th class="p-4">Courier</th><th class="p-4">Service</th><th class="p-4">Progress</th><th class="p-4">Estimated delivery</th><th class="p-4">Recipient</th><th class="p-4">Label</th></tr></thead><tbody class="divide-y divide-slate-800">
@forelse(($orders['Orders'] ?? []) as $order)
<tr><td class="p-4">{{ $order['OrderLineId'] ?? '—' }}</td><td class="p-4">{{ $order['Courier'] ?? '—' }}</td><td class="p-4">{{ $order['Service'] ?? '—' }}</td><td class="p-4"><span @class(['rounded-full px-2 py-1 text-xs font-semibold', 'bg-emerald-500/10 text-emerald-300' => $order['Tracking']['Delivered'] ?? false, 'bg-sky-500/10 text-sky-300' => ! ($order['Tracking']['Delivered'] ?? false)])>{{ ($order['Tracking']['Delivered'] ?? false) ? 'Delivered' : 'In transit' }}</span></td><td class="p-4">{{ $order['EstimatedDeliveryDate'] ?? '—' }}</td><td class="p-4">{{ $order['DeliveryAddress']['ContactName'] ?? '—' }}</td><td class="p-4">@if(! empty($order['Hash']) && ! empty($order['OrderLineId']))<a class="font-semibold text-brand hover:underline" href="{{ route('shipping.label', $order['OrderLineId']) }}">Download PDF</a>@else<span class="text-slate-500">Unavailable</span>@endif</td></tr>
@empty<tr><td colspan="7" class="p-6 text-slate-400">{{ $historyAvailable ? 'No shipments in Parcel2Go account history.' : 'Shipment history is unavailable until Parcel2Go grants access to this client.' }}</td></tr>@endforelse
</tbody></table>
</section>@endsection
+2 -1
View File
@@ -108,8 +108,9 @@ class IntegrationSettingsTest extends TestCase
$this->assertDatabaseCount('integration_settings', 0); $this->assertDatabaseCount('integration_settings', 0);
$this->actingAs($admin)->put(route('settings.integrations.update', 'parcel2go'), [ $this->actingAs($admin)->put(route('settings.integrations.update', 'parcel2go'), [
'scope' => 'public-api my-profile', 'scope' => 'public-api my-profile', 'collection_postcode' => 'SW1A 1AA',
])->assertRedirect(route('settings.index')); ])->assertRedirect(route('settings.index'));
$this->assertSame('public-api my-profile', IntegrationSetting::findOrFail('parcel2go')->settings['scope']); $this->assertSame('public-api my-profile', IntegrationSetting::findOrFail('parcel2go')->settings['scope']);
$this->assertSame('SW1A 1AA', IntegrationSetting::findOrFail('parcel2go')->settings['collection_postcode']);
} }
} }
+69 -9
View File
@@ -3,6 +3,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\EtsyConnection; use App\Models\EtsyConnection;
use App\Models\IntegrationSetting;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase; use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
@@ -65,6 +66,7 @@ class IntegrationsTest extends TestCase
public function test_shipping_quote_sends_parcel_to_parcel2go(): void public function test_shipping_quote_sends_parcel_to_parcel2go(): void
{ {
$this->pendingEtsyOrder();
Http::preventStrayRequests(); Http::preventStrayRequests();
Http::fake([ Http::fake([
'https://auth.example.test/token' => Http::response(['access_token' => 'test-token']), 'https://auth.example.test/token' => Http::response(['access_token' => 'test-token']),
@@ -75,11 +77,11 @@ class IntegrationsTest extends TestCase
]); ]);
config()->set('services.parcel2go', ['client_id' => 'id', 'client_secret' => 'secret', 'auth_url' => 'https://auth.example.test/token', 'base_url' => 'https://shipping.example.test/api']); config()->set('services.parcel2go', ['client_id' => 'id', 'client_secret' => 'secret', 'auth_url' => 'https://auth.example.test/token', 'base_url' => 'https://shipping.example.test/api']);
$admin = User::factory()->create(['role' => 'admin']); $admin = User::factory()->create(['role' => 'admin']);
$details = ['collection_postcode' => 'SW1A 1AA', 'delivery_postcode' => 'M1 1AE', 'weight' => 2, 'length' => 10, 'width' => 10, 'height' => 10, 'value' => 30]; $details = ['receipt_id' => 77, 'delivery_postcode' => 'SW1A 1AA', 'weight' => 2, 'length' => 10, 'width' => 10, 'height' => 10];
$this->actingAs($admin)->post(route('shipping.quote'), $details)->assertRedirect(route('shipping')); $this->actingAs($admin)->post(route('shipping.quote'), $details)->assertRedirect(route('shipping'));
$this->actingAs($admin)->get(route('shipping'))->assertOk()->assertSee('Evri')->assertSee('£5.50'); $this->actingAs($admin)->get(route('shipping'))->assertOk()->assertSee('Evri')->assertSee('£5.50')->assertDontSee('name="delivery_postcode"', false);
Http::assertSent(fn ($request) => $request->url() === 'https://shipping.example.test/api/quotes' && $request['Parcels'][0]['Weight'] === 2); Http::assertSent(fn ($request) => $request->url() === 'https://shipping.example.test/api/quotes' && $request['Parcels'][0]['Weight'] === 2 && $request['DeliveryAddress']['Postcode'] === 'M1 1AE' && $request['CollectionAddress']['Postcode'] === 'SW1A 1AA');
} }
public function test_shipping_page_works_with_public_api_scope_without_profile_access(): void public function test_shipping_page_works_with_public_api_scope_without_profile_access(): void
@@ -89,7 +91,7 @@ class IntegrationsTest extends TestCase
return $request['scope'] === 'public-api' return $request['scope'] === 'public-api'
? Http::response(['access_token' => 'test-token']) ? Http::response(['access_token' => 'test-token'])
: Http::response(['error' => 'invalid_scope'], 400); : Http::response(['error' => 'invalid_scope'], 400);
}]); }, 'https://shipping.example.test/api/me/orders/detail' => Http::response([], 403)]);
config()->set('services.parcel2go', [ config()->set('services.parcel2go', [
'client_id' => 'id', 'client_secret' => 'secret', 'client_id' => 'id', 'client_secret' => 'secret',
'auth_url' => 'https://auth.example.test/token', 'auth_url' => 'https://auth.example.test/token',
@@ -98,8 +100,45 @@ class IntegrationsTest extends TestCase
]); ]);
$this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('shipping')) $this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('shipping'))
->assertOk()->assertSee('Parcel2Go connected for quotes and collections') ->assertOk()->assertSee('Pending Etsy orders')->assertSee('Parcel2Go denied shipment history')
->assertDontSee('Parcel2Go is unavailable'); ->assertDontSee('Parcel2Go is unavailable');
Http::assertSentCount(2);
}
public function test_shipping_shows_existing_shipments_when_basic_access_can_read_history(): void
{
Http::preventStrayRequests();
Http::fake([
'https://auth.example.test/token' => Http::response(['access_token' => 'test-token']),
'https://shipping.example.test/api/me/orders/detail' => Http::response(['Orders' => [[
'OrderLineId' => 123, 'Courier' => 'Evri', 'Service' => 'Standard',
'Tracking' => ['Delivered' => true], 'DeliveryAddress' => ['ContactName' => 'Buyer'],
'EstimatedDeliveryDate' => '2026-10-15', 'Hash' => 'hash',
]]]),
]);
config()->set('services.parcel2go', [
'client_id' => 'id', 'client_secret' => 'secret',
'auth_url' => 'https://auth.example.test/token',
'base_url' => 'https://shipping.example.test/api',
'scope' => 'public-api',
]);
$this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('shipping'))
->assertOk()->assertSee('Existing shipments')->assertSee('Delivered')->assertSee('Buyer')
->assertDontSee('Parcel2Go denied shipment history');
Http::assertSentCount(2);
}
public function test_shipping_rejects_a_quote_for_an_order_that_is_not_pending(): void
{
$this->pendingEtsyOrder();
Http::preventStrayRequests();
$admin = User::factory()->create(['role' => 'admin']);
$this->actingAs($admin)->post(route('shipping.quote'), [
'receipt_id' => 999, 'weight' => 2, 'length' => 10, 'width' => 10, 'height' => 10,
'delivery_postcode' => 'SW1A 1AA',
])->assertRedirect(route('shipping'))->assertSessionHasErrors('quote');
Http::assertSentCount(1); Http::assertSentCount(1);
} }
@@ -110,7 +149,7 @@ class IntegrationsTest extends TestCase
return $request['scope'] === 'public-api' return $request['scope'] === 'public-api'
? Http::response(['access_token' => 'test-token']) ? Http::response(['access_token' => 'test-token'])
: Http::response(['error' => 'invalid_scope'], 400); : Http::response(['error' => 'invalid_scope'], 400);
}]); }, 'https://shipping.example.test/api/me/orders/detail' => Http::response([], 403)]);
config()->set('services.parcel2go', [ config()->set('services.parcel2go', [
'client_id' => 'id', 'client_secret' => 'secret', 'client_id' => 'id', 'client_secret' => 'secret',
'auth_url' => 'https://auth.example.test/token', 'auth_url' => 'https://auth.example.test/token',
@@ -119,10 +158,9 @@ class IntegrationsTest extends TestCase
]); ]);
$this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('shipping')) $this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('shipping'))
->assertOk()->assertSee('Parcel2Go rejected the selected API access') ->assertOk()->assertSee('Parcel2Go denied shipment history')
->assertDontSee('Parcel2Go connected, but account history is unavailable')
->assertDontSee('Parcel2Go is unavailable'); ->assertDontSee('Parcel2Go is unavailable');
Http::assertSentCount(2); Http::assertSentCount(3);
} }
public function test_shipping_label_is_downloaded_for_an_order_in_account_history(): void public function test_shipping_label_is_downloaded_for_an_order_in_account_history(): void
@@ -142,6 +180,7 @@ class IntegrationsTest extends TestCase
public function test_collection_booking_uses_quoted_service_and_available_date(): void public function test_collection_booking_uses_quoted_service_and_available_date(): void
{ {
$this->pendingEtsyOrder();
Http::preventStrayRequests(); Http::preventStrayRequests();
Http::fake([ Http::fake([
'https://auth.example.test/token' => Http::response(['access_token' => 'test-token']), 'https://auth.example.test/token' => Http::response(['access_token' => 'test-token']),
@@ -153,6 +192,7 @@ class IntegrationsTest extends TestCase
config()->set('services.parcel2go', ['client_id' => 'id', 'client_secret' => 'secret', 'auth_url' => 'https://auth.example.test/token', 'base_url' => 'https://shipping.example.test/api']); config()->set('services.parcel2go', ['client_id' => 'id', 'client_secret' => 'secret', 'auth_url' => 'https://auth.example.test/token', 'base_url' => 'https://shipping.example.test/api']);
$session = [ $session = [
'shipping_quote' => ['collection_postcode' => 'SW1A 1AA', 'delivery_postcode' => 'M1 1AE', 'weight' => 2, 'length' => 10, 'width' => 10, 'height' => 10, 'value' => 30], 'shipping_quote' => ['collection_postcode' => 'SW1A 1AA', 'delivery_postcode' => 'M1 1AE', 'weight' => 2, 'length' => 10, 'width' => 10, 'height' => 10, 'value' => 30],
'shipping_receipt_id' => 77,
'shipping_selected_service' => 'evri-collection', 'shipping_selected_service' => 'evri-collection',
'shipping_quotes' => [['Service' => ['Slug' => 'evri-collection', 'CollectionType' => 'Collection']]], 'shipping_quotes' => [['Service' => ['Slug' => 'evri-collection', 'CollectionType' => 'Collection']]],
'shipping_collection_dates' => [['CollectionDate' => '2026-10-15T10:00:00Z']], 'shipping_collection_dates' => [['CollectionDate' => '2026-10-15T10:00:00Z']],
@@ -249,4 +289,24 @@ class IntegrationsTest extends TestCase
])->assertUnprocessable()->assertJsonValidationErrors('targetTemperature'); ])->assertUnprocessable()->assertJsonValidationErrors('targetTemperature');
Http::assertNothingSent(); Http::assertNothingSent();
} }
private function pendingEtsyOrder(): void
{
config()->set('services.etsy', ['keystring' => 'key', 'shared_secret' => 'secret']);
EtsyConnection::create([
'shop_id' => 42, 'shop_name' => 'HickLab3D',
'access_token' => '123.token', 'refresh_token' => '123.refresh',
'expires_at' => now()->addHour(),
]);
IntegrationSetting::create([
'provider' => 'parcel2go',
'settings' => ['collection_postcode' => 'SW1A 1AA'],
]);
Http::fake([
'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => [[
'receipt_id' => 77, 'name' => 'Buyer', 'zip' => 'M1 1AE', 'city' => 'Manchester', 'country_iso' => 'GB',
'grandtotal' => ['amount' => 3000, 'divisor' => 100, 'currency_code' => 'GBP'],
]]]),
]);
}
} }