Máy POS đen với hoá đơn giấy dài trên nền cam — minh hoạ payment flow trong Shopware 6.7
PHP

Migrate payment handler lên Shopware 6.7 mất bao lâu? Docs nói 20 dòng, thực tế thì…

Máy POS đen với hoá đơn giấy dài trên nền cam
Photo by Towfiqu barbhuiya on Unsplash

Tháng rồi mình merge một cái PR migrate payment plugin từ Shopware 6.6 lên 6.7. Diff 23 dòng. Reviewer approve trong bốn phút. Ai cũng vui.

Ba ngày sau, checkout trên production bắt đầu chậm bất thường với đúng một payment method.

Cái diff 23 dòng đó, ai cũng tin

Docs của Shopware viết rất gọn. Bỏ SynchronousPaymentHandlerInterface với AsynchronousPaymentHandlerInterface, extend AbstractPaymentHandler, gom năm cái service tag về một cái shopware.payment.method. Xong.

Rồi cuối đoạn migration, docs bảo bạn nhớ “add your own order data loading”.

Sáu chữ. Đó là chỗ mình mất nguyên tuần.

Order biến đi đâu rồi?

Ở 6.6, handler nhận AsyncPaymentTransactionStruct, và trong đó có sẵn nguyên cái OrderEntity — kèm line items, deliveries, addresses, customer. Bạn gọi $transaction->getOrder()->getOrderCustomer()->getEmail() là có email luôn, khỏi nghĩ.

Ở 6.7, signature thành thế này:

public function pay(
    Request $request,
    PaymentTransactionStruct $transaction,
    Context $context,
    ?Struct $validateStruct
): ?RedirectResponse

PaymentTransactionStruct chỉ còn ba thứ đáng kể: getOrderTransactionId(), getReturnUrl(), và getRecurring(). Không có order.

Shopware bỏ nó đi có lý do, và lý do đó đúng. Struct cũ load nguyên cây association cho mọi payment method, kể cả cái invoice handler chỉ cần đúng transaction id. Bạn trả tiền cho một đống JOIN mà phần lớn handler không đụng tới.

Nhưng “đúng về mặt kiến trúc” với “an toàn khi migrate” là hai chuyện khác nhau.

Load lại order sao cho đỡ ngu

Cách mà mình — và mình đoán là phần lớn người migrate — làm đầu tiên:

$criteria = new Criteria([$transaction->getOrderTransactionId()]);
$criteria->addAssociation('order.lineItems');
$criteria->addAssociation('order.deliveries.shippingOrderAddress.country');
$criteria->addAssociation('order.billingAddress.country');
$criteria->addAssociation('order.orderCustomer');
$criteria->addAssociation('order.currency');
$criteria->addAssociation('order.transactions.paymentMethod');

$orderTransaction = $this->orderTransactionRepository
    ->search($criteria, $context)
    ->first();

Nghĩa là: copy y chang cây association của 6.6 vào handler của mình, cho chắc ăn.

Chúc mừng. Bạn vừa dựng lại đúng cái vấn đề mà Shopware bỏ struct cũ để tránh. Chỉ khác là bây giờ nó nằm trong plugin của bạn, và không còn ai review nó nữa.

Cái làm mình đau là dòng order.transactions.paymentMethod. Order của khách B2B thường có năm sáu transaction vì retry với partial capture, DAL hydrate hết, mỗi transaction kéo theo payment method của nó. Trên staging với data seed thì chẳng thấy gì. Trên prod của client, đơn B2B có ba trăm line item.

Cách sửa buồn cười ở chỗ nó dễ:

$criteria = new Criteria([$transaction->getOrderTransactionId()]);
$criteria->addAssociation('order.orderCustomer');
$criteria->addAssociation('order.currency');
$criteria->addAssociation('order.billingAddress.country');

Load đúng cái PSP cần cho request đó. Với gateway mình đang làm thì là amount, currency ISO, email khách, billing country. Hết.

Cái giá phải trả: mỗi lần PSP đổi payload, bạn phải nhớ quay lại sửa Criteria. Quên thì bạn chẳng được cái exception nào cả. Bạn được một cái null nằm giữa mapper. Debug lúc 11 giờ đêm thì vui lắm.

Mình vẫn chọn đánh đổi đó. Một cái null gây lỗi ồn ào rẻ hơn một cái query âm thầm ăn vài trăm millisecond mỗi lần checkout.

finalize() chạy xong không có nghĩa là tiền đã về

Chuyện này không mới ở 6.7, nhưng lúc migrate nó lòi ra nên nói luôn.

finalize() được gọi khi khách bị redirect từ PSP về shop. Ai cũng viết y như docs:

public function finalize(
    Request $request,
    PaymentTransactionStruct $transaction,
    Context $context
): void {
    if ($request->query->getBoolean('cancel')) {
        throw PaymentException::customerCanceled(
            $transaction->getOrderTransactionId(),
            'Customer canceled'
        );
    }

    $this->transactionStateHandler->paid($transaction->getOrderTransactionId(), $context);
}

Vấn đề: webhook của PSP không xếp hàng chờ cái browser của khách. Với mấy gateway EU mình đang làm, webhook về server nhanh hơn redirect khá thường xuyên — khách còn kẹt ở màn hình 3-D Secure, tab bị treo, hoặc đang xài 4G trên tàu điện.

Webhook về trước, controller của bạn set state sang paid. Rồi khách quay lại, finalize() chạy, gọi paid() lần nữa. State machine ném IllegalTransitionException, Shopware bắt lấy và biến nó thành lỗi checkout.

Đơn đã trả tiền rồi. Mà khách thì thấy màn hình đỏ.

OrderTransactionStateHandler::paid() chỉ là wrapper mỏng quanh StateMachineRegistry::transition(). Nó không idempotent. Không có chỗ nào trong docs nói nó idempotent — mình tự mặc định vậy, vì… mình muốn nó vậy.

Cách mình xử lý bây giờ: đọc state hiện tại trước, và coi IllegalTransitionException là tín hiệu “có thằng tới trước rồi”, không phải lỗi.

$state = $orderTransaction->getStateMachineState()->getTechnicalName();

if ($state === OrderTransactionStates::STATE_PAID) {
    return;
}

try {
    $this->transactionStateHandler->paid($transaction->getOrderTransactionId(), $context);
} catch (IllegalTransitionException $e) {
    $this->logger->info('Transaction was already settled by webhook', [
        'orderTransactionId' => $transaction->getOrderTransactionId(),
    ]);
}

Cái if ở trên không thừa dù đã có try. Nó giữ log sạch cho trường hợp bình thường, còn catch lo cái race thật sự, khi hai process transition cách nhau vài millisecond.

Vẫn còn khoảng hở nếu webhook handler và finalize() chạy đúng lúc. Muốn bịt hẳn thì phải lock ở tầng dưới. Mình chưa làm, vì tần suất thấp và log cho thấy catch gánh đủ. Shop nào chạy vài nghìn đơn một ngày với 3-D Secure thì nên tính lại.

Khi nào thì đừng migrate

Nếu plugin của bạn có cả validate(), prepared payment và recurring, đừng làm big-bang trong một PR. Ba method đó có ba đường gọi khác nhau, và supports() quyết định refund với recurring có được gọi hay không:

public function supports(
    PaymentHandlerType $type,
    string $paymentMethodId,
    Context $context
): bool {
    return $type === PaymentHandlerType::REFUND;
}

Viết sai chỗ này thì refund im lặng không chạy. Không exception, không log. Chỉ là admin bấm nút refund và không có gì xảy ra cả.

Còn một cái dễ quên: từ 6.7, technicalName là bắt buộc cho payment method do plugin tạo. Thiếu nó, plugin có thể không install hay activate được. Đặt prefix theo tên plugin, kiểu swag_example-example_payment.

Và nếu đang xài payPartially(), đổi sang paidPartially() luôn đi. Nó deprecated rồi, 6.8 sẽ bỏ.

Mình viết bài này lúc đang ngồi trên 6.7.11. Ai migrate rồi mà gặp cái gì khác cái mình kể thì comment cho mình biết, gom đủ mình viết tiếp phần lock cho webhook. Peace!

Ảnh: Towfiqu barbhuiya trên Unsplash (Unsplash License) — https://unsplash.com/photos/payment-terminal-with-white-receipt-xkArbdUcUeE

Dang Nguyen Hai (Mark) is a senior fullstack engineer in Ho Chi Minh City with 13+ years building e-commerce backends, payment systems and the AI features that sit on top of them. He works with Shopware and Laravel, and writes here about PHP internals, e-commerce architecture and getting AI features to behave in production.

One comment on “Migrate payment handler lên Shopware 6.7 mất bao lâu? Docs nói 20 dòng, thực tế thì…

Reply