Skip to content

Commit aa01754

Browse files
authored
Add account-wide payment callback watching (#1)
* Add account-wide callback watching * Address callback watch review feedback
1 parent a6af782 commit aa01754

10 files changed

Lines changed: 909 additions & 60 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ quickpay callbacks:watch --to=http://127.0.0.1:8000/quickpay/callback
6262
Leave the watcher running while testing the payment flow. It signs and sends
6363
payment updates to your local callback handler.
6464

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

6771
```bash

‎SECURITY.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,9 @@ commands do not add a second confirmation prompt. Avoid destinations you do not
6161
control, since payment callbacks contain merchant and transaction data even
6262
after credential redaction.
6363

64+
Running `callbacks:watch` without a payment ID or `--order-id` watches the
65+
account. It may forward data from any payment changed during that session, not
66+
only the payment involved in the developer's current checkout flow. Use a
67+
selector when the destination should receive data for only one payment.
68+
6469
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.

‎app/Callbacks/Input/CallbackRequest.php‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,24 @@ private function __construct(
1616

1717
public static function from(mixed $payment, mixed $order, mixed $destination): self
1818
{
19+
return self::create($payment, $order, $destination, selectorRequired: true);
20+
}
21+
22+
public static function forWatch(mixed $payment, mixed $order, mixed $destination): self
23+
{
24+
return self::create($payment, $order, $destination, selectorRequired: false);
25+
}
26+
27+
private static function create(
28+
mixed $payment,
29+
mixed $order,
30+
mixed $destination,
31+
bool $selectorRequired,
32+
): self {
1933
$hasPayment = $payment !== null && $payment !== '';
2034
$hasOrder = is_string($order) && $order !== '';
2135

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

‎app/Callbacks/Resolution/PaymentLocator.php‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,13 @@
33
namespace App\Callbacks\Resolution;
44

55
use App\Callbacks\Watching\CallbackPollingException;
6+
use App\Quickpay\Pagination\LinkHeaderParser;
7+
use App\Quickpay\Pagination\PaginationTargetCanonicalizer;
68
use App\Quickpay\QuickpayClient;
79
use App\Quickpay\QuickpayResponse;
10+
use DateTimeImmutable;
11+
use DateTimeZone;
12+
use InvalidArgumentException;
813
use UnexpectedValueException;
914

1015
/**
@@ -19,6 +24,74 @@
1924
{
2025
public function __construct(private QuickpayClient $quickpay) {}
2126

27+
/** @return array<int, QuickpayResponse> */
28+
public function changedBetween(DateTimeImmutable $minimum, DateTimeImmutable $maximum): array
29+
{
30+
$utc = new DateTimeZone('UTC');
31+
$query = [
32+
'timestamp' => 'updated_at',
33+
'min_time' => $minimum->setTimezone($utc)->format('Y-m-d H:i:s O'),
34+
'max_time' => $maximum->setTimezone($utc)->format('Y-m-d H:i:s O'),
35+
'operations_size' => 0,
36+
'page_size' => 100,
37+
];
38+
$response = $this->changedPage('/payments', $query);
39+
$pageCount = 1;
40+
$seen = [PaginationTargetCanonicalizer::fromQuery('/payments', $query) => true];
41+
42+
$ids = [];
43+
44+
while (true) {
45+
foreach ($response->json as $payment) {
46+
$id = is_array($payment) && ! array_is_list($payment) ? ($payment['id'] ?? null) : null;
47+
48+
if ((! is_int($id) && ! is_string($id)) || (string) $id === '') {
49+
throw new UnexpectedValueException('Quickpay returned a changed-payment row without a valid payment ID.');
50+
}
51+
52+
$ids[(string) $id] = true;
53+
}
54+
55+
$next = LinkHeaderParser::next($response->header('Link'));
56+
57+
if ($next === null) {
58+
break;
59+
}
60+
61+
if ($pageCount >= 100) {
62+
throw new InvalidArgumentException('Pagination exceeded the configured maximum of 100 pages.');
63+
}
64+
65+
$canonicalNext = PaginationTargetCanonicalizer::canonical($next);
66+
67+
if (isset($seen[$canonicalNext])) {
68+
throw new InvalidArgumentException('Quickpay returned a pagination cycle.');
69+
}
70+
71+
$seen[$canonicalNext] = true;
72+
$response = $this->changedPage($next);
73+
$pageCount++;
74+
}
75+
76+
return array_map($this->byId(...), array_keys($ids));
77+
}
78+
79+
/** @param array<string, mixed> $query */
80+
private function changedPage(string $path, array $query = []): QuickpayResponse
81+
{
82+
$response = $this->quickpay->get($path, $query);
83+
84+
if (! $response->successful()) {
85+
throw $this->pollingFailure($response);
86+
}
87+
88+
if (! is_array($response->json) || ! array_is_list($response->json)) {
89+
throw new UnexpectedValueException('Quickpay returned a malformed changed-payment response.');
90+
}
91+
92+
return $response;
93+
}
94+
2295
public function byId(string $paymentId): QuickpayResponse
2396
{
2497
$response = $this->quickpay->get('/payments/'.rawurlencode($paymentId));

0 commit comments

Comments
 (0)