Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ quickpay callbacks:watch --to=http://127.0.0.1:8000/quickpay/callback
Leave the watcher running while testing the payment flow. It signs and sends
payment updates to your local callback handler.

With no selector, the watcher forwards new operation callbacks for every
payment changed after it becomes ready. Pass a payment ID or `--order-id` to
narrow the watch to one payment. Existing operations are not replayed.

## Development

```bash
Expand Down
5 changes: 5 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,9 @@ commands do not add a second confirmation prompt. Avoid destinations you do not
control, since payment callbacks contain merchant and transaction data even
after credential redaction.

Running `callbacks:watch` without a payment ID or `--order-id` watches the
account. It may forward data from any payment changed during that session, not
only the payment involved in the developer's current checkout flow. Use a
selector when the destination should receive data for only one payment.

Quickpay's hosted API, Manager, payment window, and merchant configuration are outside this project's control and should be reported to Quickpay through its official channels.
16 changes: 15 additions & 1 deletion app/Callbacks/Input/CallbackRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,24 @@ private function __construct(

public static function from(mixed $payment, mixed $order, mixed $destination): self
{
return self::create($payment, $order, $destination, selectorRequired: true);
}

public static function forWatch(mixed $payment, mixed $order, mixed $destination): self
{
return self::create($payment, $order, $destination, selectorRequired: false);
}

private static function create(
mixed $payment,
mixed $order,
mixed $destination,
bool $selectorRequired,
): self {
$hasPayment = $payment !== null && $payment !== '';
$hasOrder = is_string($order) && $order !== '';

if (! $hasPayment && ! $hasOrder) {
if ($selectorRequired && ! $hasPayment && ! $hasOrder) {
throw new InvalidArgumentException('Provide a payment ID or --order-id.');
}

Expand Down
73 changes: 73 additions & 0 deletions app/Callbacks/Resolution/PaymentLocator.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
namespace App\Callbacks\Resolution;

use App\Callbacks\Watching\CallbackPollingException;
use App\Quickpay\Pagination\LinkHeaderParser;
use App\Quickpay\Pagination\PaginationTargetCanonicalizer;
use App\Quickpay\QuickpayClient;
use App\Quickpay\QuickpayResponse;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
use UnexpectedValueException;

/**
Expand All @@ -19,6 +24,74 @@
{
public function __construct(private QuickpayClient $quickpay) {}

/** @return array<int, QuickpayResponse> */
public function changedBetween(DateTimeImmutable $minimum, DateTimeImmutable $maximum): array
{
$utc = new DateTimeZone('UTC');
$query = [
'timestamp' => 'updated_at',
'min_time' => $minimum->setTimezone($utc)->format('Y-m-d H:i:s O'),
'max_time' => $maximum->setTimezone($utc)->format('Y-m-d H:i:s O'),
'operations_size' => 0,
'page_size' => 100,
];
$response = $this->changedPage('/payments', $query);
$pageCount = 1;
$seen = [PaginationTargetCanonicalizer::fromQuery('/payments', $query) => true];

$ids = [];

while (true) {
foreach ($response->json as $payment) {
$id = is_array($payment) && ! array_is_list($payment) ? ($payment['id'] ?? null) : null;

if ((! is_int($id) && ! is_string($id)) || (string) $id === '') {
throw new UnexpectedValueException('Quickpay returned a changed-payment row without a valid payment ID.');
}

$ids[(string) $id] = true;
}

$next = LinkHeaderParser::next($response->header('Link'));

if ($next === null) {
break;
}

if ($pageCount >= 100) {
throw new InvalidArgumentException('Pagination exceeded the configured maximum of 100 pages.');
}

$canonicalNext = PaginationTargetCanonicalizer::canonical($next);

if (isset($seen[$canonicalNext])) {
throw new InvalidArgumentException('Quickpay returned a pagination cycle.');
}

$seen[$canonicalNext] = true;
$response = $this->changedPage($next);
$pageCount++;
}

return array_map($this->byId(...), array_keys($ids));
}

/** @param array<string, mixed> $query */
private function changedPage(string $path, array $query = []): QuickpayResponse
{
$response = $this->quickpay->get($path, $query);

if (! $response->successful()) {
throw $this->pollingFailure($response);
}

if (! is_array($response->json) || ! array_is_list($response->json)) {
throw new UnexpectedValueException('Quickpay returned a malformed changed-payment response.');
}

return $response;
}

public function byId(string $paymentId): QuickpayResponse
{
$response = $this->quickpay->get('/payments/'.rawurlencode($paymentId));
Expand Down
Loading
Loading