JFIF $ d d 7 * Z qn )ZP 0 !1 A "#a$2Qr D8 a Ri [f \m I ykI w0 c u F c Rı?lO7 к _f˓ [C $殷WF< _W ԣ s Kc ëI zyQy /_LK ℂ ;C",p F A : /] =H ~ , l s / 9 ć: [=/#f ; ) x { ٛE Q )~ = 𘙲r*2~ a _V = ' ku m F D } KYYC )( { * g& f `툪 r y ` = ^ c J. I ]( * `wq 1dđ #̩ ͑ 0 ; H ] u 搂 @:~ ו K L N s h } O I R* 8 :2 ! lD JV o ( 3 = M ( z Ȱ+i *N A r6K n S l ) ! J J Ӂ* % ݉?|D } d5: eP0 R; {$X' x F @ .ÊB {,WJ uQ ɲRI ;9 Q E琯 6 2fT .DU J ;*c P A\IL Nj! J ۱+ O\ ͔ ] ޒ S J ȧ c%A N ol ՎprU L Zԛe r E2= X D Xg VQeӓ k y P7 U* o m Q I s ,K `) 6\G3t?pgjr m ۛجw luGt f h 9uyP 0D;Uڽ"OXl i f $ ) &|ML 0 Z rm1 [ H XP l P R0 'G=i 2N +0 e 2 ]] 9 VT P O 7 h(F * 癈 ' = Q VZD F,d ߬ ~ T X G[ `le 69C R(!S2! P < 0 x < !1AQ "Raq 02Br #S CTb ? Ζ "]mH5WR 7 k.ۛ ! } Q~ + yԏ z |@ T20 S~ K e k *zF f^2X *( @ 8 r ? C I uI|֓>^Ex LgNU Y+{.R Ѫ τV YT D I 6 2 '8Y2 7'\T P.6 d &˦ @V q i |8 -O Ε ] ʔ U= T L 8= ;6c | !qfF3 a ů& ~$ l} 'NW U s$U k^S V : U#6w + +s &r+n ڐ { @ 29gL u " T Ù M=6 (^ "7 r}=6Yݾ lC u hquymp Ǧ G jhs ǜNl ɻ} o 7 #S6 a w 4 ! OS r D 5 7 % | ?x> L | / nD 6?/8 w # [ ) L 7 +6〼T A T g ! %5 Mm Z /c -{1_Je "| ^$ 'O & ޱմ < H vNqϡQ$ < 6 &Nݛ ֨tǻN (9 n ru~'όN sMG& ,uH Kw *Ī 5@ u6 BԒ i YN * f 0 _ GhKW u lT <nj- 0{ M L~ | /a v\ E = ?1 \ɟuq Ђ Y O a K ޡP q $ H8o Q˼EV ~ k 0 h' L Pp M -M" m^ !6l nմ 쮝C U T . ! :vMǨb h ډ Vc r @~p # !1QAa q ? AL0L x LU 4 d ; x " 16ad0 7.\ߋ 0 = P l v/ " c A Ay A|2pO8"d MV #1] = \ " ˡ ] k v I9 d ۓ & q ) ǐq E ꞅ v "P )q X &Q З non]я u ג./ ; gZ$ M QР 2M ]h ni 0 P $ێ 3CɏGjώy : m c # S_ l@ '_ \m|C 2`K _ 1 cIvGc $? PCQ S+D ? K aײ k Fr b 8 ύ NEدg ĝ U 3 6 #Ik+ v { v i ( ވ ? a e) X xy/*@Q Ǽ Ծ ) $ IWO l c G; !i k{ gOŀ L ('x T rb $ w) R $ & N1E td U3Uȉ1p M"N* (DNy d96.(jQ) X 5 c QɎMy W ? Q *!R > 6=7)Xj 5`J ] e 8%t ! + ' ! 1Q 5 !1 AQaq ё #2 "0BRb ? G t^ ## .l lQ T $ v , ,m 㵜5u bV =sY+ @d { N ! dn O < .- B; _w J t 6;Q Jd. Q c%p{ 1 ,sND dF H I0 Гo X ш e黅X ۢ F :)[ F G X ƹ / w _c MeD ,ʡc c . W D tA $ j@ : ) - #u c1< @ۗ9F ) KJ- hpP] _ x [ q B lb p ʖ w q " LFGd ƶ * s +ډ_Zc " ? % t[I P 6 J ]# =ɺ V v v CGs Gh1 > )6 |e y ? L ӣ m,4 G W Ui ` ] uJVo VDG< SB 6ϏQ @ TiU l y O U 0 kfV ~ ~}S Z @ *WU Ui ## ; s /[ = ! 7}" W N ] ' (L ! ~ y5 g9T̅J kbM' +s :S +B) v @ M j e C f j E 0 Y\Qn z G 1 д~ W o{T 9?` Rm yhsy3 !HAD ] m c1 ~ 2 L S u7x T ;j$ `} 4-> L# vzŏ I L S ֭ T { r jGK C ; b pU= - ` Bs K. S F w4 M q] Z dH S 0 ) tLg
<?php
/**
* @copyright © Melograno Ventures. All rights reserved.
* @licence See LICENCE.md for license details.
*/
namespace AmeliaBooking\Infrastructure\Repository\Cache;
use AmeliaBooking\Domain\Entity\Cache\Cache;
use AmeliaBooking\Domain\Factory\Cache\CacheFactory;
use AmeliaBooking\Infrastructure\Repository\AbstractRepository;
use AmeliaBooking\Infrastructure\Common\Exceptions\QueryExecutionException;
use AmeliaBooking\Infrastructure\Connection;
use AmeliaBooking\Infrastructure\WP\InstallActions\DB\Payment\PaymentsTable;
/**
* Class CacheRepository
*
* @package AmeliaBooking\Infrastructure\Repository\Cache
*/
class CacheRepository extends AbstractRepository
{
/**
* @param Connection $connection
* @param string $table
*/
public function __construct(
Connection $connection,
$table
) {
parent::__construct($connection, $table);
}
public const FACTORY = CacheFactory::class;
/**
* How long a paid-finalization claim is trusted before it is treated as abandoned.
*
* Long enough to outlast a slow booking (notifications, calendar sync, invoices) and
* short enough that a customer whose finalization died is booked by the next webhook
* delivery rather than left paid and unbooked.
*/
public const PAID_CLAIM_STALE_SECONDS = 300;
/**
* @param Cache $entity
*
* @return int
* @throws QueryExecutionException
*/
public function add($entity)
{
$data = $entity->toArray();
$params = [
':name' => $data['name'],
':data' => $data['data'],
];
try {
$statement = $this->connection->prepare(
"INSERT INTO
{$this->table}
(
`name`,
`data`
) VALUES (
:name,
:data
)"
);
$statement->execute($params);
} catch (\Exception $e) {
throw new QueryExecutionException('Unable to add data in ' . __CLASS__ . '. ' . $e->getMessage(), $e->getCode(), $e);
}
return $this->connection->lastInsertId();
}
/**
* @param int $id
* @param Cache $entity
*
* @return bool
* @throws QueryExecutionException
*/
public function update($id, $entity)
{
$data = $entity->toArray();
$params = [
':paymentId' => $data['paymentId'],
':data' => $data['data'],
':id' => $id,
];
try {
$statement = $this->connection->prepare(
"UPDATE {$this->table}
SET
`paymentId` = :paymentId,
`data` = :data
WHERE
id = :id"
);
$statement->execute($params);
} catch (\Exception $e) {
throw new QueryExecutionException('Unable to save data in ' . __CLASS__ . '. ' . $e->getMessage(), $e->getCode(), $e);
}
return true;
}
/**
* @param int $id
* @param string $name
*
* @return Cache|null
* @throws QueryExecutionException
*/
public function getByIdAndName($id, $name)
{
try {
$statement = $this->connection->prepare(
$this->selectQuery() . " WHERE id = :id AND name = :name"
);
$params = [
':id' => $id,
':name' => $name
];
$statement->execute($params);
$row = $statement->fetch();
} catch (\Exception $e) {
throw new QueryExecutionException('Unable to find by id in ' . __CLASS__ . '. ' . $e->getMessage(), $e->getCode(), $e);
}
if (!$row) {
return null;
}
return call_user_func([static::FACTORY, 'create'], $row);
}
/**
* Lock a cache row for the current transaction so finalization can be claimed atomically.
*
* @param int $id
*
* @return Cache|null
* @throws QueryExecutionException
*/
public function getByIdForUpdate($id)
{
try {
$statement = $this->connection->prepare(
$this->selectQuery() . ' WHERE id = :id FOR UPDATE'
);
$statement->execute(
[
':id' => $id,
]
);
$row = $statement->fetch();
} catch (\Exception $e) {
throw new QueryExecutionException(
'Unable to lock cache by id in ' . __CLASS__ . '. ' . $e->getMessage(),
$e->getCode(),
$e
);
}
if (!$row) {
return null;
}
return call_user_func([static::FACTORY, 'create'], $row);
}
/**
* Atomically claim an unpaid cache row for paid finalization.
* Returns true only when this caller transitions the row away from unpaid.
* "captureFailed" counts as unpaid: the booking was stored but the authorized funds were never
* captured, so a later retry that does capture them has to be able to claim the row.
*
* @param int $id
*
* @return bool
* @throws QueryExecutionException
*/
public function claimPendingAsPaid($id)
{
// 'captureFailed' is claimable too (AMR-2555): an authorization whose capture failed is
// still an unpaid row that a later delivery must be able to take over. The claim goes
// through claimAsPaid() so it is stamped with the time it was taken, which is what lets
// an abandoned finalization be told apart from one that is still running (AMR-2304).
return $this->claimAsPaid(
$id,
"JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.status')) IN ('', 'null', 'pending', 'captureFailed')",
'Unable to claim unpaid cache in '
);
}
/**
* Finds Cache rows whose linked payment is still "pending" for the given gateway
* and was created before the given cutoff, so a cron job can reconcile them.
*
* @param string $gateway
* @param string $createdBeforeDateTime
*
* @return Cache[]
* @throws QueryExecutionException
*/
public function getPendingByGatewayOlderThan($gateway, $createdBeforeDateTime)
{
$paymentsTable = PaymentsTable::getTableName();
try {
$statement = $this->connection->prepare(
"SELECT c.* FROM {$this->table} c
INNER JOIN {$paymentsTable} p ON p.id = c.paymentId
WHERE p.gateway = :gateway AND p.status = 'pending' AND p.created < :createdBeforeDateTime"
);
$statement->execute(
[
':gateway' => $gateway,
':createdBeforeDateTime' => $createdBeforeDateTime,
]
);
$rows = $statement->fetchAll();
} catch (\Exception $e) {
throw new QueryExecutionException('Unable to find pending cache rows in ' . __CLASS__ . '. ' . $e->getMessage(), $e->getCode(), $e);
}
$caches = [];
foreach ($rows as $row) {
$caches[] = call_user_func([static::FACTORY, 'create'], $row);
}
return $caches;
}
/**
* @param string $paymentIntentId
*
* @return Cache|null
* @throws QueryExecutionException
*/
public function getByStripePaymentIntentId($paymentIntentId): ?Cache
{
try {
$statement = $this->connection->prepare(
$this->selectQuery() . " WHERE JSON_VALID(`data`)
AND JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.stripePaymentIntentId')) = :paymentIntentId
ORDER BY id DESC
LIMIT 1"
);
$statement->execute(
[
':paymentIntentId' => $paymentIntentId,
]
);
$row = $statement->fetch();
} catch (\Exception $e) {
throw new QueryExecutionException(
'Unable to find cache by Stripe PaymentIntent id in ' . __CLASS__ . '. ' . $e->getMessage(),
$e->getCode(),
$e
);
}
if (!$row) {
return null;
}
return call_user_func([static::FACTORY, 'create'], $row);
}
/**
* Atomically claim a cache row for paid finalization unless it is already paid.
* Allows failed/canceled rows so a succeeded PaymentIntent can still be booked.
*
* A claim that never produced a response is taken over once it is older than
* $staleAfterSeconds: the holder was killed mid-finalization (PHP timeout, OOM), and
* without a takeover the row would keep reading as "paid" while no booking exists -
* the money is captured and nothing ever retries.
*
* Only a claim this class stamped is taken over, which is what isPaidClaimAbandoned()
* reports on: a paid row with no stamp predates the stamp or was written by another flow,
* and re-running finalization on one of those could book a second time.
*
* @param int $id
* @param int $staleAfterSeconds
*
* @return bool
* @throws QueryExecutionException
*/
public function claimForPaidFinalization($id, $staleAfterSeconds = self::PAID_CLAIM_STALE_SECONDS): bool
{
return $this->claimAsPaid(
$id,
"JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.status')) <> 'paid'
OR (
JSON_EXTRACT(`data`, '$.response') IS NULL
AND JSON_EXTRACT(`data`, '$.paidClaimedAt') IS NOT NULL
AND CAST(
JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.paidClaimedAt')) AS UNSIGNED
) < :staleBefore
)",
'Unable to claim cache for paid finalization in ',
[':staleBefore' => time() - max((int)$staleAfterSeconds, 0)]
);
}
/**
* Whether a row that reads as paid was left that way by a caller that died before it
* produced a booking.
*
* Only a claim this class stamped counts: a paid row with no stamp predates the stamp or
* was written by another flow, and re-running finalization on one of those could book a
* second time for a payment that was already booked.
*
* @param array $cacheData
* @param int $staleAfterSeconds
*
* @return bool
*/
public static function isPaidClaimAbandoned(
array $cacheData,
$staleAfterSeconds = self::PAID_CLAIM_STALE_SECONDS
): bool {
if (!empty($cacheData['response'])) {
return false;
}
if (empty($cacheData['paidClaimedAt'])) {
return false;
}
return (time() - (int)$cacheData['paidClaimedAt']) >= max((int)$staleAfterSeconds, 0);
}
/**
* Atomically flips a cache row's status to "paid" when it still matches the given
* status predicate. Returns true only for the caller that performed the transition.
*
* The claim is stamped with the time it was taken, so a claim whose holder died can be
* told apart from one that is still running - see claimForPaidFinalization().
*
* @param int $id
* @param string $statusPredicate SQL predicate the current status must satisfy
* @param string $errorMessage
* @param array $extraParams Additional bindings the predicate refers to
*
* @return bool
* @throws QueryExecutionException
*/
private function claimAsPaid($id, $statusPredicate, $errorMessage, array $extraParams = []): bool
{
try {
$statement = $this->connection->prepare(
"UPDATE {$this->table}
SET `data` = JSON_SET(
CASE
WHEN JSON_VALID(`data`) THEN `data`
ELSE '{}'
END,
'$.status',
'paid',
'$.paidClaimedAt',
:claimedAt
)
WHERE id = :id
AND (
NOT JSON_VALID(`data`)
OR JSON_EXTRACT(`data`, '$.status') IS NULL
OR {$statusPredicate}
)"
);
$statement->execute(
array_merge(
[
':id' => $id,
':claimedAt' => time(),
],
$extraParams
)
);
return $statement->rowCount() > 0;
} catch (\Exception $e) {
throw new QueryExecutionException(
$errorMessage . __CLASS__ . '. ' . $e->getMessage(),
$e->getCode(),
$e
);
}
}
}