From 7b288012e202985c18e1a2472ea5090b321457eb Mon Sep 17 00:00:00 2001 From: Yanuar Date: Thu, 2 Jul 2026 11:03:02 +0700 Subject: [PATCH 1/4] feat(transaction-process): cache credit pin temporarily --- .../TransactionProcessController.php | 5 + lang/kiriminaja-official-id_ID.po | 23 ++- templates/transaction-process/view/index.php | 188 +++++++++++++++++- tests/RequestPickupPaymentFlowTest.php | 37 ++++ 4 files changed, 250 insertions(+), 3 deletions(-) diff --git a/inc/Controllers/TransactionProcessController.php b/inc/Controllers/TransactionProcessController.php index 2c569c7..a02da45 100644 --- a/inc/Controllers/TransactionProcessController.php +++ b/inc/Controllers/TransactionProcessController.php @@ -712,6 +712,11 @@ public function renderWooActionModalTemplatesForKiriofPage() + + diff --git a/lang/kiriminaja-official-id_ID.po b/lang/kiriminaja-official-id_ID.po index 53b1f41..d578def 100644 --- a/lang/kiriminaja-official-id_ID.po +++ b/lang/kiriminaja-official-id_ID.po @@ -1,6 +1,6 @@ msgid "" msgstr "" -"Project-Id-Version: KiriminAja Official 2.3.0\n" +"Project-Id-Version: KiriminAja Official 2.2.6\n" "Report-Msgid-Bugs-To: \n" "Last-Translator: \n" "Language-Team: \n" @@ -68,6 +68,11 @@ msgstr "District wajib diisi" msgid "Field Kelurahan is a required field." msgstr "Field Kelurahan wajib diisi." +#: inc/Controllers/CheckoutController.php +#, php-format +msgid "Address length must be greater than %d" +msgstr "Panjang alamat harus lebih dari %d" + #: inc/Controllers/CheckoutController.php:854 msgid "Shipping is a required field" msgstr "Shipping wajib diisi" @@ -233,6 +238,9 @@ msgstr "Jadwal Tersedia" msgid "-- Select schedule --" msgstr "-- Pilih jadwal --" +msgid "Browser secure storage is unavailable. PIN will not be remembered." +msgstr "Penyimpanan aman browser tidak tersedia. PIN tidak akan diingat." + msgid "Close" msgstr "Tutup" @@ -242,6 +250,19 @@ msgstr "Tidak ada jadwal pickup yang tersedia." msgid "Please select a pickup schedule." msgstr "Silakan pilih jadwal pickup." +#, php-format +msgid "Remember PIN on this browser for %d minutes" +msgstr "Ingat PIN di browser ini selama %d menit" + +msgid "Saved PIN expired. Please enter your PIN again." +msgstr "PIN tersimpan sudah kedaluwarsa. Silakan masukkan PIN Anda lagi." + +msgid "Saved PIN was cleared. Please enter your latest PIN again." +msgstr "PIN tersimpan telah dihapus. Silakan masukkan PIN terbaru Anda lagi." + +msgid "Saved PIN will be reused until it expires on this browser." +msgstr "PIN tersimpan akan digunakan kembali sampai masa berlakunya habis di browser ini." + #: inc/Base/Helper.php:37 #: inc/Base/Helper.php:57 msgid "Canceled" diff --git a/templates/transaction-process/view/index.php b/templates/transaction-process/view/index.php index 80f07c4..20aef11 100644 --- a/templates/transaction-process/view/index.php +++ b/templates/transaction-process/view/index.php @@ -8,6 +8,15 @@ $kiriof_helper = kiriof_helper(); $kiriof_homeUrl = home_url(); $kiriof_adminUrl = $kiriof_homeUrl . '/wp-admin'; +$kiriof_current_user = wp_get_current_user(); +$kiriof_pin_cache_ttl = (int) apply_filters('kiriof_pin_cache_ttl', 15 * MINUTE_IN_SECONDS, $kiriof_current_user); +if ($kiriof_pin_cache_ttl < MINUTE_IN_SECONDS) { + $kiriof_pin_cache_ttl = MINUTE_IN_SECONDS; +} +$kiriof_pin_cache_label = sprintf( + __('Remember PIN on this browser for %d minutes', 'kiriminaja-official'), + (int) ceil($kiriof_pin_cache_ttl / MINUTE_IN_SECONDS) +); /** * @var string $locale @@ -623,6 +632,16 @@ const kjPickScheduleLabel = ''; const kjConfirmPinLabel = ''; const kjValidateLabel = ''; +const kjPinCacheConfig = { +key: , +ttl: , +userHash: , +siteHash: , +rememberLabel: , +expiredLabel: , +invalidatedLabel: , +unsupportedLabel: , +}; const kjUpdateRequestPickupCount = () => { const pickupCount = $transactionCheckboxes().filter(':checked:not(:disabled)[data-can-pickup="1"]').length; const printCount = $transactionCheckboxes().filter(':checked:not(:disabled)[data-can-print="1"]').length; @@ -726,7 +745,7 @@ function kiriofSetModalState($modal, state) { } else if (state==='pin' ) { $modal.find('.kiriof-modal-state-pin').show(); $modal.find('#btn-next').text(kjValidateLabel); - kjFocusPinInput($modal); + kjPrepareCreditPinStep($modal); } else { $modal.find('.kiriof-modal-state-content').show(); } @@ -785,6 +804,159 @@ function kjResetPinRetryState($modal) { $modal.data('kiriofPinCooldownTimer', null); } + function kjGetPinRememberCheckbox($modal) { + return $modal.find('#kiriof-pin-remember'); + } + + function kjGetPinCacheNotice($modal) { + return $modal.find('.kiriof-pin-cache-notice'); + } + + function kjSetPinCacheNotice($modal, message, tone) { + const $notice = kjGetPinCacheNotice($modal); + if (!message) { + $notice.hide().text('').removeAttr('data-tone'); + return; + } + + $notice.attr('data-tone', tone || 'info').text(message).show(); + } + + function kjCanUsePinCache() { + return window.isSecureContext && window.crypto && window.crypto.subtle && window.localStorage; + } + + async function kjGetPinCacheKeyMaterial() { + const rawKey = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(`${kjPinCacheConfig.userHash}:${kjPinCacheConfig.siteHash}`)); + return window.crypto.subtle.importKey('raw', rawKey, 'AES-GCM', false, ['encrypt', 'decrypt']); + } + + function kjGetPinCacheRecord() { + if (!kjCanUsePinCache()) { + return null; + } + + try { + const rawValue = window.localStorage.getItem(kjPinCacheConfig.key); + if (!rawValue) { + return null; + } + + const parsedValue = JSON.parse(rawValue); + if (!parsedValue || parsedValue.userHash !== kjPinCacheConfig.userHash || parsedValue.siteHash !== kjPinCacheConfig.siteHash) { + window.localStorage.removeItem(kjPinCacheConfig.key); + return null; + } + + if (!parsedValue.expiresAt || Date.now() >= parsedValue.expiresAt) { + window.localStorage.removeItem(kjPinCacheConfig.key); + return { expired: true }; + } + + return parsedValue; + } catch (error) { + window.localStorage.removeItem(kjPinCacheConfig.key); + return null; + } + } + + async function kjDecryptCachedPin(record) { + if (!record || !record.ciphertext || !record.iv) { + return ''; + } + + const key = await kjGetPinCacheKeyMaterial(); + const iv = Uint8Array.from(atob(record.iv), (character) => character.charCodeAt(0)); + const ciphertext = Uint8Array.from(atob(record.ciphertext), (character) => character.charCodeAt(0)); + const decrypted = await window.crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext); + return new TextDecoder().decode(decrypted).replace(/\D/g, '').substring(0, 6); + } + + async function kjPersistCachedPin($modal, pin) { + if (!kjCanUsePinCache()) { + kjSetPinCacheNotice($modal, kjPinCacheConfig.unsupportedLabel, 'warning'); + return false; + } + + const rememberPin = kjGetPinRememberCheckbox($modal).is(':checked'); + if (!rememberPin || !pin || pin.length !== 6) { + return false; + } + + const key = await kjGetPinCacheKeyMaterial(); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await window.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(pin)); + const payload = { + userHash: kjPinCacheConfig.userHash, + siteHash: kjPinCacheConfig.siteHash, + expiresAt: Date.now() + (kjPinCacheConfig.ttl * 1000), + iv: btoa(String.fromCharCode(...iv)), + ciphertext: btoa(String.fromCharCode(...new Uint8Array(encrypted))), + }; + window.localStorage.setItem(kjPinCacheConfig.key, JSON.stringify(payload)); + kjSetPinCacheNotice($modal, kjPinCacheConfig.rememberLabel, 'success'); + return true; + } + + function kjClearCachedPin($modal, reason) { + if (window.localStorage) { + window.localStorage.removeItem(kjPinCacheConfig.key); + } + kjGetPinRememberCheckbox($modal).prop('checked', false); + if (reason === 'expired') { + kjSetPinCacheNotice($modal, kjPinCacheConfig.expiredLabel, 'warning'); + } else if (reason === 'invalid') { + kjSetPinCacheNotice($modal, kjPinCacheConfig.invalidatedLabel, 'warning'); + } else if (!reason) { + kjSetPinCacheNotice($modal, '', 'info'); + } + } + + async function kjRestoreCachedPin($modal) { + if (!kjCanUsePinCache()) { + return false; + } + + const record = kjGetPinCacheRecord(); + if (!record) { + return false; + } + + if (record.expired) { + kjClearCachedPin($modal, 'expired'); + return false; + } + + try { + const pin = await kjDecryptCachedPin(record); + if (!pin || pin.length !== 6) { + kjClearCachedPin($modal, 'invalid'); + return false; + } + + kjSetPinValue($modal, pin); + kjGetPinRememberCheckbox($modal).prop('checked', true); + kjSetPinCacheNotice($modal, kjPinCacheConfig.rememberLabel, 'success'); + return true; + } catch (error) { + kjClearCachedPin($modal, 'invalid'); + return false; + } + } + + function kjPrepareCreditPinStep($modal) { + kjEnsurePinInputReady($modal); + if (!kjCanUsePinCache()) { + kjSetPinCacheNotice($modal, kjPinCacheConfig.unsupportedLabel, 'warning'); + } + window.setTimeout(function() { + void kjRestoreCachedPin($modal).finally(function() { + kjFocusPinInput($modal); + kjUpdatePickupButton($modal); + }); + }, 0); + } + function kjNormalizePinErrorData($modal, data) { const normalized = Object.assign({}, data || {}); const error = normalized.error || ''; @@ -835,6 +1007,9 @@ function kjNormalizePinErrorData($modal, data) { const $modal = kiriofGetRequestPickupModal(); kjResetPinRetryState($modal); + kjSetPinValue($modal, ''); + kjGetPinRememberCheckbox($modal).prop('checked', false); + kjSetPinCacheNotice($modal, '', 'info'); kiriofSetModalState($modal, 'loading'); $modal.find('#btn-next').prop('disabled', true); @@ -997,6 +1172,11 @@ function kjLoadPaymentMethodConfig($modal, totalFee) { kjResetPinRetryState($modal); $modal.find('#kiriof-pin-widget').removeAttr('invalid'); $modal.find('.kiriof-pin-error').hide().text(''); + kjSetPinCacheNotice($modal, '', 'info'); + } else { + void kjRestoreCachedPin($modal).finally(function() { + kjUpdatePickupButton($modal); + }); } kjUpdatePickupButton($modal); }); @@ -1162,6 +1342,7 @@ function kjSubmitPickup($modal, ids, schedule, paymentMethod, pin, closeModal) { kiriofSetModalState($modal, 'content'); $errMsg.text('*').show(); } else if (errCode === 'PIN_INVALID' || errCode === 'PIN_MAX_ATTEMPT_REACHED') { + kjClearCachedPin($modal, 'invalid'); kiriofSetModalState($modal, 'pin'); kjShowPinError($modal, resp?.data || {}, resp?.message || errCode); } else { @@ -1249,6 +1430,7 @@ function kjSubmitPickup($modal, ids, schedule, paymentMethod, pin, closeModal) { complete: function(pinResp) { const pinData = JSON.parse(pinResp.responseText).data; if (pinData?.status !== 200) { + kjClearCachedPin($modal, 'invalid'); kiriofSetModalState($modal, 'pin'); const pinErr = pinData?.data; if (pinErr?.error === 'PIN_MAX_ATTEMPT_REACHED') { @@ -1261,7 +1443,9 @@ function kjSubmitPickup($modal, ids, schedule, paymentMethod, pin, closeModal) { return; } kjResetPinRetryState($modal); - kjSubmitPickup($modal, orderIds, schedule, paymentMethod, pin, closeModal); + void kjPersistCachedPin($modal, pin).finally(function() { + kjSubmitPickup($modal, orderIds, schedule, paymentMethod, pin, closeModal); + }); } }); return; diff --git a/tests/RequestPickupPaymentFlowTest.php b/tests/RequestPickupPaymentFlowTest.php index d3a969e..843d898 100644 --- a/tests/RequestPickupPaymentFlowTest.php +++ b/tests/RequestPickupPaymentFlowTest.php @@ -255,6 +255,43 @@ public function cod_only_pickup_does_not_fall_back_to_qris(): void ); } + #[Test] + public function request_pickup_credit_pin_supports_temporary_encrypted_browser_cache(): void + { + $transactionProcessContent = file_get_contents(PLUGIN_DIR . '/templates/transaction-process/view/index.php'); + $controllerContent = file_get_contents(PLUGIN_DIR . '/inc/Controllers/TransactionProcessController.php'); + + $this->assertStringContainsString( + "const kjPinCacheConfig = {", + $transactionProcessContent, + 'Transaction process should expose browser PIN cache config for the request pickup flow' + ); + + $this->assertStringContainsString( + 'window.crypto.subtle.encrypt', + $transactionProcessContent, + 'Temporary PIN storage should encrypt the PIN before writing to browser storage' + ); + + $this->assertStringContainsString( + 'window.localStorage.setItem(kjPinCacheConfig.key, JSON.stringify(payload));', + $transactionProcessContent, + 'Temporary PIN cache should persist encrypted browser state per user key' + ); + + $this->assertStringContainsString( + 'kjClearCachedPin($modal, \'invalid\');', + $transactionProcessContent, + 'Invalid or outdated PIN responses should clear the saved browser PIN cache' + ); + + $this->assertStringContainsString( + 'id="kiriof-pin-remember"', + $controllerContent, + 'Credit PIN modal should render a remember PIN checkbox for temporary browser cache opt-in' + ); + } + #[Test] public function payment_list_fees_column_subtracts_platform_shipping_discount(): void { From 855800618e2654e48b822fe19218b51b40fb5887 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Thu, 2 Jul 2026 11:44:00 +0700 Subject: [PATCH 2/4] fix: ci test not passed --- inc/Controllers/ShippingProcessController.php | 27 +++++++----- templates/transaction-process/view/index.php | 1 + templates/woocommerce/cart/cart-shipping.php | 42 +++++++++---------- ...hopVerseBlockCheckoutCompatibilityTest.php | 4 +- 4 files changed, 40 insertions(+), 34 deletions(-) diff --git a/inc/Controllers/ShippingProcessController.php b/inc/Controllers/ShippingProcessController.php index 47e83df..a4ffb9a 100644 --- a/inc/Controllers/ShippingProcessController.php +++ b/inc/Controllers/ShippingProcessController.php @@ -107,18 +107,25 @@ private function markTransactionsPrinted( array $orderIds ) } global $wpdb; - $placeholders = implode( ',', array_fill( 0, count( $orderIds ), '%s' ) ); - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Print status is updated immediately after successful label fetch. - $wpdb->query( - $wpdb->prepare( - "UPDATE {$wpdb->prefix}kiriminaja_transactions - SET is_printed = %d, printed_at = %s - WHERE order_id IN ({$placeholders})", - 1, - current_time( 'mysql' ), - ...$orderIds + $prepared_query = $wpdb->prepare( + "UPDATE {$wpdb->prefix}kiriminaja_transactions + SET is_printed = %d, printed_at = %s + WHERE order_id IN (" . implode( ',', array_fill( 0, count( $orderIds ), '%s' ) ) . ')', + array_merge( + array( + 1, + current_time( 'mysql' ), + ), + $orderIds ) ); + + if ( ! is_string( $prepared_query ) || '' === $prepared_query ) { + return; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Print status is updated immediately after successful label fetch. + $wpdb->query( $prepared_query ); } private function logResiPrintFailure( string $reason, array $context = array() ): void diff --git a/templates/transaction-process/view/index.php b/templates/transaction-process/view/index.php index 20aef11..f6b7856 100644 --- a/templates/transaction-process/view/index.php +++ b/templates/transaction-process/view/index.php @@ -14,6 +14,7 @@ $kiriof_pin_cache_ttl = MINUTE_IN_SECONDS; } $kiriof_pin_cache_label = sprintf( + /* translators: %d: cached PIN duration in minutes. */ __('Remember PIN on this browser for %d minutes', 'kiriminaja-official'), (int) ceil($kiriof_pin_cache_ttl / MINUTE_IN_SECONDS) ); diff --git a/templates/woocommerce/cart/cart-shipping.php b/templates/woocommerce/cart/cart-shipping.php index 1e5775a..3ff5845 100644 --- a/templates/woocommerce/cart/cart-shipping.php +++ b/templates/woocommerce/cart/cart-shipping.php @@ -71,32 +71,32 @@ $kiriof_session_rate_meta = ( function_exists( 'WC' ) && WC() && isset( WC()->session ) && WC()->session ) ? (array) WC()->session->get( 'kiriof_shipping_coupon_rate_meta', array() ) : array(); - $kiriof_get_shipping_method_label_html = static function ( $method ) use ( $kiriof_session_rate_meta ) { - $kiriof_method_label_html = wp_kses_post( wc_cart_totals_shipping_method_label( $method ) ); - $kiriof_rate_meta = $kiriof_session_rate_meta[ $method->id ] ?? array(); + $kiriof_get_shipping_method_label_html = static function ( $kiriof_method ) use ( $kiriof_session_rate_meta ) { + $kiriof_method_label_html = wp_kses_post( wc_cart_totals_shipping_method_label( $kiriof_method ) ); + $kiriof_rate_meta = $kiriof_session_rate_meta[ $kiriof_method->id ] ?? array(); $kiriof_original_cost = (float) ( $kiriof_rate_meta['original_cost'] ?? 0.0 ); $kiriof_discount_amount = (float) ( $kiriof_rate_meta['discount_amount'] ?? 0.0 ); $kiriof_badge = (string) ( $kiriof_rate_meta['badge'] ?? '' ); $kiriof_notice = (string) ( $kiriof_rate_meta['notice'] ?? '' ); - $kiriof_current_cost = isset( $method->cost ) ? (float) $method->cost : 0.0; + $kiriof_current_cost = isset( $kiriof_method->cost ) ? (float) $kiriof_method->cost : 0.0; - if ( method_exists( $method, 'get_meta' ) ) { + if ( method_exists( $kiriof_method, 'get_meta' ) ) { if ( 0.0 === $kiriof_original_cost ) { - $kiriof_original_cost = (float) $method->get_meta( 'kiriof_shipping_coupon_original_cost', true ); + $kiriof_original_cost = (float) $kiriof_method->get_meta( 'kiriof_shipping_coupon_original_cost', true ); } if ( 0.0 === $kiriof_discount_amount ) { - $kiriof_discount_amount = max( 0.0, (float) $method->get_meta( 'kiriof_shipping_coupon_discount_amount', true ) ); + $kiriof_discount_amount = max( 0.0, (float) $kiriof_method->get_meta( 'kiriof_shipping_coupon_discount_amount', true ) ); } if ( '' === $kiriof_badge ) { - $kiriof_badge = (string) $method->get_meta( 'kiriof_shipping_coupon_badge', true ); + $kiriof_badge = (string) $kiriof_method->get_meta( 'kiriof_shipping_coupon_badge', true ); } if ( '' === $kiriof_notice ) { - $kiriof_notice = (string) $method->get_meta( 'kiriof_shipping_coupon_notice', true ); + $kiriof_notice = (string) $kiriof_method->get_meta( 'kiriof_shipping_coupon_notice', true ); } } if ( $kiriof_discount_amount > 0 && $kiriof_original_cost > $kiriof_current_cost ) { - $kiriof_method_label_html = esc_html( (string) $method->get_label() ); + $kiriof_method_label_html = esc_html( (string) $kiriof_method->get_label() ); if ( '' !== $kiriof_badge ) { $kiriof_method_label_html .= ' ' . esc_html( $kiriof_badge ) . ''; } @@ -113,7 +113,7 @@ ); $kiriof_method_label_html .= ''; } elseif ( '' !== $kiriof_notice ) { - $kiriof_method_label_html = esc_html( (string) $method->get_label() ); + $kiriof_method_label_html = esc_html( (string) $kiriof_method->get_label() ); $kiriof_method_label_html .= '' . wp_kses_post( wc_price( $kiriof_current_cost ) ) . ''; $kiriof_method_label_html .= '' . esc_html( $kiriof_notice ) . ''; } @@ -130,11 +130,11 @@ class="wc-enhanced-select kiriof-classic-shipping-method-select" data-index="" aria-label="" > - + - @@ -143,22 +143,20 @@ class="wc-enhanced-select kiriof-classic-shipping-method-select"