ryan před 2 týdny
rodič
revize
1420df10bf

+ 2 - 0
.gitignore

@@ -119,3 +119,5 @@ vendor/yiisoft/yii2/di/Instance.php
 vendor/yiisoft/yii2/di/NotInstantiableException.php
 vendor/yiisoft/yii2/di/ServiceLocator.php
 frontendApi/runtime/logs/
+
+docs

+ 41 - 0
common/helpers/GuestDeclarationDraftStatus.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace common\helpers;
+
+final class GuestDeclarationDraftStatus
+{
+    const DRAFT = 'draft';
+    const PENDING_PAYMENT = 'pending_payment';
+    const PROCESSING = 'processing';
+    const COMPLETED = 'completed';
+    const PAYMENT_FAILED = 'payment_failed';
+    const PAYMENT_INITIALIZING = 'payment_initializing';
+    const EXPIRED = 'expired';
+    const MANUAL_REVIEW = 'manual_review';
+
+    private const TRANSITIONS = [
+        self::DRAFT => [self::PAYMENT_INITIALIZING, self::EXPIRED],
+        self::PENDING_PAYMENT => [self::PROCESSING, self::PAYMENT_FAILED, self::EXPIRED, self::MANUAL_REVIEW],
+        self::PAYMENT_INITIALIZING => [self::DRAFT, self::PENDING_PAYMENT, self::MANUAL_REVIEW],
+        self::PROCESSING => [self::COMPLETED, self::MANUAL_REVIEW],
+        self::PAYMENT_FAILED => [self::PAYMENT_INITIALIZING, self::EXPIRED],
+        self::COMPLETED => [],
+        self::EXPIRED => [],
+        self::MANUAL_REVIEW => [],
+    ];
+
+    public static function all(): array
+    {
+        return array_keys(self::TRANSITIONS);
+    }
+
+    public static function canTransition(string $from, string $to): bool
+    {
+        return in_array($to, self::TRANSITIONS[$from] ?? [], true);
+    }
+
+    public static function isExpired(?int $expiresAt, ?int $now = null): bool
+    {
+        return $expiresAt !== null && $expiresAt <= ($now ?? time());
+    }
+}

+ 25 - 4
common/helpers/PayStack.php

@@ -4,6 +4,11 @@ namespace common\helpers;
 
 class PayStack
 {
+    public static function verifyWebhookSignature(string $rawBody, string $signature, string $secretKey): bool
+    {
+        return $signature !== '' && hash_equals(hash_hmac('sha512', $rawBody, $secretKey), $signature);
+    }
+
     /**
      * 根据运行环境加载secretKey
      */
@@ -16,17 +21,24 @@ class PayStack
     /**
      * 支付.
      */
-    public static function transactionInit($currency, $amount, $email)
+    public static function transactionInit($currency, $amount, $email, array $metadata = [], string $reference = '')
     {
         $secretKey = self::getSecretKey();
 
         LoggerTool::info('env: ' . YII_ENV);
 
         if (YII_ENV != YII_ENV_PROD) {
+            if ($reference === '') {
+                $reference = bin2hex(random_bytes(16));
+            }
             return [
                 'status' => true,
                 'data' => [
-                    'amount' => $amount
+                    'amount' => (int) round($amount * 100),
+                    'currency' => strtoupper((string) $currency),
+                    'reference' => $reference,
+                    'authorization_url' => 'https://checkout.paystack.com/' . $reference,
+                    'access_code' => $reference,
                 ]
             ];
         }
@@ -35,8 +47,15 @@ class PayStack
             $url = "https://api.paystack.co/transaction/initialize";
             $fields = [
                 'email' => $email,
-                'amount' => $amount * 100
+                'amount' => (int) round($amount * 100),
+                'currency' => strtoupper((string) $currency),
             ];
+            if ($reference !== '') {
+                $fields['reference'] = $reference;
+            }
+            if ($metadata) {
+                $fields['metadata'] = json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+            }
             $fields_string = http_build_query($fields);
             //open connection
             $curl = curl_init();
@@ -51,6 +70,8 @@ class PayStack
             ));
             //So that curl_exec returns the contents of the cURL; rather than echoing it
             curl_setopt($curl,CURLOPT_RETURNTRANSFER, true);
+            curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 10);
+            curl_setopt($curl, CURLOPT_TIMEOUT, 30);
             //execute post
             $result = curl_exec($curl);
             $err = curl_error($curl);
@@ -167,4 +188,4 @@ class PayStack
             ];
         }
     }
-}
+}

+ 19 - 6
common/helpers/Tool.php

@@ -582,14 +582,27 @@ class Tool {
     public static function approachOrderCall($call)
     {
         try {
+            $data = $call['data'] ?? [];
+            $customFields = $data['metadata']['custom_fields'] ?? [];
+            $safeData = [
+                'id' => $data['id'] ?? null,
+                'status' => $data['status'] ?? null,
+                'reference' => $data['reference'] ?? '',
+                'amount' => $data['amount'] ?? null,
+                'currency' => $data['currency'] ?? null,
+                'paid_at' => $data['paid_at'] ?? null,
+                'metadata' => ['custom_fields' => [
+                    ['value' => $customFields[0]['value'] ?? ''],
+                    ['value' => $customFields[1]['value'] ?? ''],
+                ]],
+            ];
             $model = new ApproachOrderCall();
-            $model->sn = $call['data']['metadata']['custom_fields'][0]['value'] ?? '';
-            $model->reference = $call['data']['reference'] ?? '';
-            $model->event = $call['event'];
-            $model->data = $call['data'];
+            $model->sn = $safeData['metadata']['custom_fields'][0]['value'];
+            $model->reference = $safeData['reference'];
+            $model->event = $call['event'] ?? '';
+            $model->data = $safeData;
             $model->insert();
         } catch (Exception $e) {
-            LoggerTool::info($call);
             LoggerTool::error(sprintf('[%s] [%s] [%s]', $e->getFile(), $e->getLine(), $e->getMessage()));
         }
     }
@@ -658,4 +671,4 @@ class Tool {
 
         return $prefix . $currentDate . $no;
     }
-}
+}

+ 4 - 1
common/models/ApproachDecOrder.php

@@ -52,7 +52,10 @@ class ApproachDecOrder extends \common\components\ActiveRecord
     public function rules()
     {
         return [
-            [['USER_ID', 'TO_USER_ID','REC_USER_ID',/*'CON_USER_ID','DEC_ID', */'TYPE', 'PAID_WALLET', 'PERIOD_NUM', 'P_CALC_MONTH', 'CREATED_AT'], 'required'],
+            [['TO_USER_ID','REC_USER_ID',/*'CON_USER_ID','DEC_ID', */'TYPE', 'PAID_WALLET', 'PERIOD_NUM', 'P_CALC_MONTH', 'CREATED_AT'], 'required'],
+            [['USER_ID'], 'required', 'when' => function ($model) {
+                return $model->scenario !== 'guestDeclaration';
+            }],
             [['IS_ADMIN', 'IS_BATCH', 'PERIOD_NUM', 'CALC_MONTH', 'CREATED_AT', 'IS_DEL', 'DELETED_AT'], 'integer'],
             [['DEC_AMOUNT', 'DEC_PV'], 'number'],
             [['ID', 'DEC_SN', 'ORDER_SN', 'USER_ID', 'TO_USER_ID','REC_USER_ID','CON_USER_ID','DEC_ID', 'TYPE', 'UPDATER', 'UPDATED_AT'], 'string', 'max' => 32],

+ 83 - 0
common/models/GuestDeclarationDraft.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace common\models;
+
+use common\components\ActiveRecord;
+use common\helpers\GuestDeclarationDraftStatus;
+use Yii;
+
+/**
+ * Durable state for an unauthenticated registration order awaiting payment.
+ */
+class GuestDeclarationDraft extends ActiveRecord
+{
+    public static function tableName()
+    {
+        return '{{%GUEST_DECLARATION_DRAFT}}';
+    }
+
+    public function rules()
+    {
+        return [
+            [['ORDER_SN', 'ACCESS_TOKEN_HASH', 'PAYLOAD', 'ORDER_SNAPSHOT', 'STATUS', 'PAY_AMOUNT', 'CURRENCY', 'CREATED_AT', 'EXPIRES_AT'], 'required'],
+            [['PAYLOAD', 'ORDER_SNAPSHOT', 'PAYMENT_NOTE'], 'string'],
+            [['PAY_AMOUNT'], 'number'],
+            [['CREATED_AT', 'EXPIRES_AT', 'PAID_AT', 'COMPLETED_AT'], 'integer'],
+            [['STATUS'], 'in', 'range' => GuestDeclarationDraftStatus::all()],
+            [['ORDER_SN'], 'string', 'max' => 32],
+            [['ACCESS_TOKEN_HASH'], 'string', 'max' => 64],
+            [['CURRENCY'], 'string', 'max' => 8],
+            [['PAYMENT_REFERENCE'], 'string', 'max' => 128],
+            [['USER_ID'], 'string', 'max' => 32],
+            [['ORDER_SN'], 'unique'],
+            [['ACCESS_TOKEN_HASH'], 'unique'],
+            [['PAYMENT_REFERENCE'], 'unique'],
+        ];
+    }
+
+    public function transitionTo(string $newStatus): bool
+    {
+        if (!GuestDeclarationDraftStatus::canTransition($this->STATUS, $newStatus)) {
+            $this->addError('STATUS', 'Invalid guest declaration draft status transition.');
+            return false;
+        }
+
+        $this->STATUS = $newStatus;
+        return $this->save(false, ['STATUS', 'UPDATED_AT']);
+    }
+
+    public static function claimForProcessing(string $orderSn): bool
+    {
+        return self::updateAll(
+            ['STATUS' => GuestDeclarationDraftStatus::PROCESSING, 'UPDATED_AT' => time()],
+            [
+                'AND',
+                ['ORDER_SN' => $orderSn],
+                ['STATUS' => GuestDeclarationDraftStatus::PENDING_PAYMENT],
+                ['>', 'EXPIRES_AT', time()],
+            ]
+        ) === 1;
+    }
+
+    public function accessTokenMatches(string $accessToken): bool
+    {
+        return $accessToken !== '' && hash_equals($this->ACCESS_TOKEN_HASH, hash('sha256', $accessToken));
+    }
+
+    public function isExpired(?int $now = null): bool
+    {
+        return GuestDeclarationDraftStatus::isExpired((int) $this->EXPIRES_AT, $now);
+    }
+
+    public function getPayload(): array
+    {
+        $payload = json_decode($this->PAYLOAD, true);
+        return is_array($payload) ? $payload : [];
+    }
+
+    public function getOrderSnapshot(): array
+    {
+        $snapshot = json_decode($this->ORDER_SNAPSHOT, true);
+        return is_array($snapshot) ? $snapshot : [];
+    }
+}

+ 18 - 7
common/models/forms/ApproachDeclarationForm.php

@@ -80,6 +80,9 @@ class ApproachDeclarationForm extends Model
     private $_standardAmount;
     private $_decAmountStandard;
     public $period;
+    /** Set only by the verified guest payment finalizer. */
+    public $isGuestDeclaration = false;
+    public $guestOrderSn;
 
     // 批量报单时添加会员的REDIS里面缓存的添加的会员资料
     const REDIS_WAIT_ADD_USER = 'user:dec:waitAdd';
@@ -619,7 +622,10 @@ class ApproachDeclarationForm extends Model
                 }
 
                 // 报单中心汇率
-                $userInfo = User::findOne(\Yii::$app->user->id);
+                $userInfo = $this->isGuestDeclaration ? User::findOne($this->_decId) : User::findOne(\Yii::$app->user->id);
+                if (!$userInfo) {
+                    throw new Exception(Yii::t('app', 'stockistDoesNotExist'));
+                }
                 $decCountryId = $userInfo->COUNTRY_ID;
                 $decCountry = Countries::getById($decCountryId);
                 $decUserCurrencyRate = CurrencyConversions::getToUSDRate($decCountry['LOCAL_CURRENCY_ID']);
@@ -744,8 +750,8 @@ class ApproachDeclarationForm extends Model
         // 增加会员
         $user = new User();
         $user->USER_NAME = $this->insertUserName;
-        $user->PASSWORD_HASH = \Yii::$app->security->generatePasswordHash($this->password);
-        $user->PAY_PASSWORD = \Yii::$app->security->generatePasswordHash($this->payPassword);
+        $user->PASSWORD_HASH = $this->isGuestDeclaration ? $this->password : \Yii::$app->security->generatePasswordHash($this->password);
+        $user->PAY_PASSWORD = $this->isGuestDeclaration ? $this->payPassword : \Yii::$app->security->generatePasswordHash($this->payPassword);
         $user->NATION = 0;
         $user->REAL_NAME = $this->realName;
         $user->ID_CARD = $this->mobile;
@@ -818,13 +824,16 @@ class ApproachDeclarationForm extends Model
         $nowPeriodNum = $this->period ?: $periodObj->getNowPeriodNum();
         $nowCalcMonth = $periodObj->getYearMonth($nowPeriodNum);
 
-        $ord = date('Ymd').substr(implode(NULL, array_map('ord', str_split(substr(uniqid(), 7, 13), 1))), 0, 10);
+        $ord = $this->isGuestDeclaration && $this->guestOrderSn
+            ? substr($this->guestOrderSn, 2)
+            : date('Ymd').substr(implode(NULL, array_map('ord', str_split(substr(uniqid(), 7, 13), 1))), 0, 10);
         // 加入报单信息
         $decOrderModel = new ApproachDecOrder();
         $decOrderModel->DEC_SN = 'DS' . $ord;
         $decOrderModel->ORDER_SN = 'OS' . $ord;
         $decOrderModel->TYPE = $this->type;
-        $decOrderModel->USER_ID = \Yii::$app->user->id;
+        $decOrderModel->scenario = $this->isGuestDeclaration ? 'guestDeclaration' : $decOrderModel->scenario;
+        $decOrderModel->USER_ID = $this->isGuestDeclaration ? null : \Yii::$app->user->id;
         $decOrderModel->TO_USER_ID = $this->_insertUserId;
         $decOrderModel->DEC_AMOUNT = $this->_decAmount;
         $decOrderModel->DEC_PV = $this->_decPv;
@@ -858,7 +867,9 @@ class ApproachDeclarationForm extends Model
         $userCountry = Countries::getById($userCountryId);
         $userCurrencyRate = CurrencyConversions::getToUSDRate($userCountry['LOCAL_CURRENCY_ID']);
         // 报单中心汇率
-        $decCountryId = User::getEnCodeInfo(\Yii::$app->user->id)['COUNTRY_ID'];
+        $decCountryId = $this->isGuestDeclaration
+            ? User::getEnCodeInfo($this->_decId)['COUNTRY_ID']
+            : User::getEnCodeInfo(\Yii::$app->user->id)['COUNTRY_ID'];
         $decCountry = Countries::getById($decCountryId);
         $decUserCurrencyRate = CurrencyConversions::getToUSDRate($decCountry['LOCAL_CURRENCY_ID']);
 
@@ -889,7 +900,7 @@ class ApproachDeclarationForm extends Model
         $orderModel->WAREHOUSE = $warehouse;
         $orderModel->STATUS = \Yii::$app->params['orderStatus']['notPaid']['value'];
         $orderModel->CREATED_AT = Date::nowTime();
-        $orderModel->CREATE_USER = Info::getUserNameByUserId(\Yii::$app->user->id);
+        $orderModel->CREATE_USER = $this->isGuestDeclaration ? 'guest' : Info::getUserNameByUserId(\Yii::$app->user->id);
         $orderModel->ORDER_AMOUNT_STANDARD = $this->_decAmountStandard;
         $orderModel->PAY_AMOUNT_STANDARD = $this->_standardAmount;
         $orderModel->EXCHANGE_RATE = $userCurrencyRate;

+ 4 - 0
common/models/forms/ApproachDeclarationLoopForm.php

@@ -19,6 +19,8 @@ use common\models\ShopGoods;
 class ApproachDeclarationLoopForm extends Model
 {
     public $data;
+    public $isGuestDeclaration = false;
+    public $guestOrderSn;
 
     /**
      * @inheritdoc
@@ -118,6 +120,8 @@ class ApproachDeclarationLoopForm extends Model
             $zcUserIdCard = null;
             $model = new ApproachDeclarationForm();
             $model->scenario = $this->scenario;
+            $model->isGuestDeclaration = $this->isGuestDeclaration;
+            $model->guestOrderSn = $this->guestOrderSn;
             $model->allData = $this->data;
             foreach ($this->data as $value) {
                 // 套餐报单

+ 21 - 4
common/models/forms/ApproachOrderForm.php

@@ -166,7 +166,10 @@ class ApproachOrderForm extends Model
         }
 
         if ($this->scenario == 'verifyPayStack'){
-            if ($this->_model->STATUS != \Yii::$app->params['orderStatus']['notPaid']['value']) {
+            if (!in_array((int) $this->_model->STATUS, [
+                (int) \Yii::$app->params['orderStatus']['notPaid']['value'],
+                (int) \Yii::$app->params['orderStatus']['failPaid']['value'],
+            ], true)) {
                 $this->addError('sn', Yii::t('app', 'payTypeError'));
                 return false;
             }
@@ -304,7 +307,7 @@ class ApproachOrderForm extends Model
         }
 
         // 调用PayStack支付校验
-        LoggerTool::info([$this->note['reference'], $this->note]);
+        LoggerTool::info(['paystack_reference' => $this->note['reference'] ?? '']);
 //        $payload = PayStack::transactionVerify($this->note['reference']);
 //        LoggerTool::info($payload);
 //        if ($payload['status'] !== true) {
@@ -320,6 +323,16 @@ class ApproachOrderForm extends Model
         $db = \Yii::$app->db;
         $transaction = $db->beginTransaction();
         try {
+            if ((string) $this->status !== (string) \Yii::$app->params['orderStatus']['paid']['value']) {
+                $this->_model->STATUS = $this->status;
+                $this->_model->NOTE = json_encode($this->note);
+                if (!$this->_model->save()) {
+                    throw new Exception(Form::formatErrorsForApi($this->_model->getErrors()));
+                }
+                $transaction->commit();
+                return $this->_model;
+            }
+
             // 更新准订单状态为已支付
             $this->_model->STATUS = $this->status;
             $this->_model->NOTE = json_encode($this->note);
@@ -347,17 +360,21 @@ class ApproachOrderForm extends Model
             }
 
             // 会员报单、BA升级
-            if (in_array($orderType, ['userDec', 'baUpgrade', 'userUpgrade'])) {
+            if (in_array($orderType, ['userDec', 'guestUserDec', 'baUpgrade', 'userUpgrade'])) {
                 // 同步报单
                 $approachDecOrder = ApproachDecOrder::findOneAsArray('ORDER_SN = :ORDER_SN', [':ORDER_SN' => $this->sn]);
                 if ($approachDecOrder) {
                     unset($approachDecOrder['STATUS']);
+                    if ($orderType === 'guestUserDec') {
+                        // USER_ID is the report owner in the permanent record; the payer is never stored.
+                        $approachDecOrder['USER_ID'] = $approachDecOrder['TO_USER_ID'];
+                    }
 
                     // 同步报单
                     DecOrder::insertOne($approachDecOrder);
 
                     // 修改会员锁定状态
-                    if (in_array($orderType, ['userDec', 'baUpgrade'])) {
+                    if (in_array($orderType, ['userDec', 'guestUserDec', 'baUpgrade'])) {
                         if (!User::updateAll(['STATUS' => 1], 'ID=:USER_ID', [':USER_ID' => $approachDecOrder['TO_USER_ID']])) {
                             throw new Exception(Form::formatErrorsForApi(Yii::t('app', 'changeUserStatusError')));
                         }

+ 2 - 2
common/models/forms/UserForm.php

@@ -245,7 +245,7 @@ class UserForm extends Model
         // 获取排序为2的报单级别
         $decLevel = DeclarationLevel::getLevelFromSort(1);
         if($this->zcPv < $decLevel['PERF']){
-            if($this->allData['hasInstalment'] && $this->zcPv + 30 >= $decLevel['PERF']){
+            if(!empty($this->allData['hasInstalment']) && $this->zcPv + 30 >= $decLevel['PERF']){
             }else{
                 $this->addError($attribute, '注册'.$this->userName.'时,注册PV低于最低级别要求,不能报首购单');
             }
@@ -261,7 +261,7 @@ class UserForm extends Model
         $otherUser = User::find()->where("ID_CARD=:ID_CARD AND ID<>:ID AND IS_UNION=0 AND DELETED=0 AND (ID_CARD_PREFIX IS NULL OR ID_CARD_PREFIX='')", [':ID_CARD'=>$this->idCard, ':ID'=>$this->userId])->select('ID')->asArray()->all();
         // 拿到所有的批量报单数据
         foreach($this->allData as $decData){
-            if($decData['insertUserIdCard'] == $this->idCard){
+            if(($decData['insertUserIdCard'] ?? null) == $this->idCard && !empty($decData['toUserId'])){
                 $otherUser[] = ['ID'=>$decData['toUserId']];
             }
         }

+ 496 - 0
common/services/GuestDeclarationOrderService.php

@@ -0,0 +1,496 @@
+<?php
+
+namespace common\services;
+
+use common\helpers\GuestDeclarationDraftStatus;
+use common\helpers\Cache;
+use common\helpers\Form;
+use common\helpers\user\Info;
+use common\models\Countries;
+use common\models\Currency;
+use common\models\GuestDeclarationDraft;
+use common\models\ApproachOrder;
+use common\models\DeclarationPackage;
+use common\models\ShopGoods;
+use common\models\ShopGoodsNature;
+use common\models\Region;
+use common\models\forms\ApproachDeclarationForm;
+use common\models\forms\ApproachDeclarationLoopForm;
+use common\models\forms\ApproachOrderForm;
+use common\models\User;
+use common\models\UserNetwork;
+use common\models\UserRelation;
+use Yii;
+use yii\web\BadRequestHttpException;
+use yii\web\NotFoundHttpException;
+
+/** Handles the public, pre-payment part of a guest registration order. */
+class GuestDeclarationOrderService
+{
+    private const DRAFT_TTL = 86400;
+
+    public function createDraft(string $countryId): array
+    {
+        $country = Countries::getById($countryId);
+        if (!$country || empty($country['ACTIVE'])) {
+            throw new BadRequestHttpException('Unsupported country.');
+        }
+        $currency = Currency::getById((string) $country['LOCAL_CURRENCY_ID']);
+        if (!$currency || empty($currency['ACTIVE']) || empty($currency['CODE'])) {
+            throw new BadRequestHttpException('Payment currency is unavailable.');
+        }
+
+        $token = Yii::$app->security->generateRandomString(64);
+        $username = Info::generateWebName($countryId);
+        if (!$username) {
+            throw new \RuntimeException('Unable to generate a member number.');
+        }
+        $draft = new GuestDeclarationDraft();
+        $draft->ID = Yii::$app->security->generateRandomString(32);
+        $draft->ORDER_SN = 'OS' . strtoupper(bin2hex(random_bytes(12)));
+        $draft->ACCESS_TOKEN_HASH = hash('sha256', $token);
+        $draft->STATUS = GuestDeclarationDraftStatus::DRAFT;
+        $draft->PAYLOAD = '{}';
+        $draft->ORDER_SNAPSHOT = '{}';
+        $draft->PAY_AMOUNT = 0;
+        $draft->CURRENCY = strtoupper($currency['CODE']);
+        $draft->CREATED_AT = time();
+        $draft->EXPIRES_AT = time() + self::DRAFT_TTL;
+
+        // This is a proposal only. The number is checked again before payment completion.
+        $draft->USER_ID = null;
+        $draft->PAYLOAD = json_encode([
+            'countryId' => $countryId,
+            'insertUserName' => $username,
+        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+        if (!$draft->save()) {
+            throw new \RuntimeException('Unable to create guest order draft.');
+        }
+        if (!Yii::$app->redis->setnx('key_' . $username, $username)) {
+            $draft->delete();
+            throw new \RuntimeException('Unable to reserve a unique member number. Please retry.');
+        }
+        Yii::$app->redis->expire('key_' . $username, self::DRAFT_TTL + 600);
+
+        return [
+            'draftId' => $draft->ID,
+            'accessToken' => $token,
+            'insertUserName' => $username,
+            'expiresAt' => (int) $draft->EXPIRES_AT,
+            'currency' => $draft->CURRENCY,
+        ];
+    }
+
+    public function getDraftStatus(string $draftId, string $accessToken): array
+    {
+        $draft = $this->loadAuthorizedDraft($draftId, $accessToken);
+        if ($draft->STATUS === GuestDeclarationDraftStatus::PAYMENT_INITIALIZING && (int) $draft->UPDATED_AT < time() - 900) {
+            $draft->transitionTo(GuestDeclarationDraftStatus::MANUAL_REVIEW);
+        } elseif ($draft->isExpired() && in_array($draft->STATUS, [GuestDeclarationDraftStatus::DRAFT, GuestDeclarationDraftStatus::PENDING_PAYMENT], true)) {
+            $draft->transitionTo(GuestDeclarationDraftStatus::EXPIRED);
+        }
+
+        return [
+            'status' => $draft->STATUS,
+            'orderSn' => $draft->STATUS === GuestDeclarationDraftStatus::COMPLETED ? $draft->ORDER_SN : null,
+            'userName' => $draft->STATUS === GuestDeclarationDraftStatus::COMPLETED ? ($draft->getPayload()['insertUserName'] ?? null) : null,
+        ];
+    }
+
+    public function submitDraft(string $draftId, string $accessToken, array $input): array
+    {
+        $draft = $this->loadAuthorizedDraft($draftId, $accessToken);
+        if ($draft->isExpired()) {
+            $draft->transitionTo(GuestDeclarationDraftStatus::EXPIRED);
+            throw new BadRequestHttpException('Guest order draft has expired.');
+        }
+        if ($draft->STATUS === GuestDeclarationDraftStatus::PENDING_PAYMENT) {
+            return $this->paymentResponse($draft);
+        }
+        if ($draft->STATUS === GuestDeclarationDraftStatus::PAYMENT_INITIALIZING) {
+            throw new BadRequestHttpException('Payment initialization is in progress.');
+        }
+        if (!in_array($draft->STATUS, [GuestDeclarationDraftStatus::DRAFT, GuestDeclarationDraftStatus::PAYMENT_FAILED], true)) {
+            throw new BadRequestHttpException('Guest order draft cannot be submitted in its current state.');
+        }
+
+        foreach (['amount', 'payAmount', 'currency', 'pv', 'exchangeRate', 'goodsTitle', 'price', 'realPrice'] as $untrustedField) {
+            if (array_key_exists($untrustedField, $input)) {
+                throw new BadRequestHttpException('Price and product fields are calculated by the server.');
+            }
+        }
+
+        $payload = $draft->getPayload();
+        $countryId = (string) ($payload['countryId'] ?? '');
+        unset($input['draftId'], $input['accessToken'], $input['allData'], $input['isGuestDeclaration'], $input['guestOrderSn'], $input['_insertUserId']);
+        $input['insertUserName'] = $payload['insertUserName'] ?? '';
+        $input['countryId'] = $countryId;
+        $input['goodsId'] = $input['goodsId'] ?? [];
+        $input['goodsNum'] = $input['goodsNum'] ?? [];
+        $input['type'] = ApproachDeclarationForm::TYPE_ZC;
+        $input['payType'] = 'pay_stack';
+        $input['province'] = !empty($input['province']) ? $input['province'] : 1;
+        $input['city'] = !empty($input['city']) ? $input['city'] : 1;
+        $input['county'] = !empty($input['county']) ? $input['county'] : 1;
+        $referrerName = trim((string) ($input['conUserName'] ?? ''));
+        $sponsorName = trim((string) ($input['recUserName'] ?? ''));
+        $referrerId = Info::getUserIdByUserName($referrerName);
+        $sponsorId = Info::getUserIdByUserName($sponsorName);
+        $relation = $referrerId ? UserRelation::findOneAsArray('USER_ID=:USER_ID', [':USER_ID' => $referrerId]) : null;
+        $parentIds = $relation ? array_map('strval', explode(',', (string) $relation['PARENT_UIDS'])) : [];
+        if (!$referrerId || !$sponsorId || ((string) $sponsorId !== (string) $referrerId && !in_array((string) $sponsorId, $parentIds, true))) {
+            throw new BadRequestHttpException(Yii::t('app', 'fillSponsorUpline'));
+        }
+        $stockistName = trim((string) ($input['decUserName'] ?? ''));
+        if ($stockistName === '' || !User::find()->where(['USER_NAME' => $stockistName, 'IS_DEC' => 1])->exists()) {
+            throw new BadRequestHttpException(Yii::t('app', 'stockistDoesNotExist'));
+        }
+        if ((string) $input['province'] !== '1' && !Region::getWarehouseByCode($input['province'])) {
+            throw new BadRequestHttpException(Yii::t('app', 'deliveryTemporarilyNotSupported'));
+        }
+        $bottomPlace = UserNetwork::getBottomPlace($referrerId, '', 'left');
+        $bottom = UserNetwork::find()->select('USER_ID')->where(['LOCATION_TAG' => $bottomPlace])->asArray()->one();
+        $bottomUser = $bottom ? User::findOneAsArray(['ID' => $bottom['USER_ID']], 'USER_NAME') : null;
+        if (!$bottomUser) {
+            throw new BadRequestHttpException(Yii::t('app', 'stockistDoesNotExist'));
+        }
+        $input['conUserName'] = $bottomUser['USER_NAME'];
+        $input['recUserName'] = $referrerName;
+        $input['bottomUserName'] = $sponsorName;
+        $input['location'] = 1;
+
+        $form = new ApproachDeclarationForm();
+        $form->scenario = 'userDec';
+        foreach ($input as $name => $value) {
+            if (in_array($name, $form->safeAttributes(), true) && $name !== 'allData') {
+                $form->$name = $value;
+            }
+        }
+        $form->bottomUserName = $sponsorName;
+        $allowedInput = array_flip(array_merge($form->safeAttributes(), ['bottomUserName', 'payType', 'countryId']));
+        unset($allowedInput['allData']);
+        $input = array_intersect_key($input, $allowedInput);
+        $form->allData = [$input];
+        if (!$form->validate()) {
+            throw new BadRequestHttpException(Form::formatErrorsForApi($form->getErrors()));
+        }
+        if (User::find()->where(['USER_NAME' => $input['insertUserName']])->exists()
+            || Yii::$app->redis->get('key_' . $input['insertUserName']) !== $input['insertUserName']
+        ) {
+            throw new BadRequestHttpException(Yii::t('app', 'memberNumberExpired'));
+        }
+
+        [$amount, $pv, $goods] = $this->quote($input);
+        $levels = Cache::getDecLevelConfig();
+        $minimumPv = $levels ? min(array_map(static function ($level) { return (float) $level['PERF']; }, $levels)) : 0;
+        if ($pv < $minimumPv) {
+            throw new BadRequestHttpException(Yii::t('app', 'totalBVCanNotLessThanSelectedBV'));
+        }
+        if ((string) ($input['decWay'] ?? '') !== '1') {
+            $selectedLevel = $levels[$input['decLv'] ?? ''] ?? null;
+            if (!$selectedLevel) {
+                throw new BadRequestHttpException(Yii::t('app', 'pleaseSelectTheEntryLevel'));
+            }
+            if ($pv < (float) $selectedLevel['PERF']) {
+                throw new BadRequestHttpException(Yii::t('app', 'totalBVCanNotLessThanSelectedBV'));
+            }
+            $actualLevel = null;
+            foreach ($levels as $levelId => $level) {
+                if ($pv >= (float) $level['PERF']) {
+                    $actualLevel = $levelId;
+                }
+            }
+            if ($actualLevel !== null && (string) $input['decLv'] !== (string) $actualLevel) {
+                throw new BadRequestHttpException(Yii::t('app', 'totalBVCanNotLessThanNextSelectedLevelBV'));
+            }
+        }
+        if ($amount <= 0 || empty($input['email']) || !filter_var($input['email'], FILTER_VALIDATE_EMAIL)) {
+            throw new BadRequestHttpException('A valid payment email and positive order amount are required.');
+        }
+        $country = Countries::getById($countryId);
+        $currency = Currency::getById((string) $country['LOCAL_CURRENCY_ID']);
+        $orderSn = $draft->ORDER_SN;
+        $input['password'] = Yii::$app->security->generatePasswordHash((string) $input['password']);
+        $input['payPassword'] = Yii::$app->security->generatePasswordHash((string) $input['payPassword']);
+        $storedPayload = json_encode($input, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+        $reserved = GuestDeclarationDraft::updateAll(
+            ['STATUS' => GuestDeclarationDraftStatus::PAYMENT_INITIALIZING, 'UPDATED_AT' => time()],
+            ['AND', ['ID' => $draft->ID], ['STATUS' => $draft->STATUS], ['>' , 'EXPIRES_AT', time()]]
+        );
+        if ($reserved !== 1) {
+            $draft->refresh();
+            if ($draft->STATUS === GuestDeclarationDraftStatus::PENDING_PAYMENT) {
+                return $this->paymentResponse($draft);
+            }
+            throw new BadRequestHttpException('Guest order draft is already being submitted.');
+        }
+        $paymentReference = 'GD' . strtoupper(bin2hex(random_bytes(12)));
+        $init = \common\helpers\PayStack::transactionInit(
+            strtoupper($currency['CODE']),
+            $amount,
+            $input['email'],
+            ['custom_fields' => [
+                ['display_name' => 'Order SN', 'variable_name' => 'orderSn', 'value' => $orderSn],
+                ['display_name' => 'Order Type', 'variable_name' => 'orderType', 'value' => 'guestUserDec'],
+            ]],
+            $paymentReference
+        );
+        if (empty($init['status']) || empty($init['data']['reference']) || empty($init['data']['authorization_url'])) {
+            GuestDeclarationDraft::updateAll(
+                ['STATUS' => GuestDeclarationDraftStatus::DRAFT, 'UPDATED_AT' => time()],
+                ['AND', ['ID' => $draft->ID], ['STATUS' => GuestDeclarationDraftStatus::PAYMENT_INITIALIZING]]
+            );
+            throw new \RuntimeException('Unable to initialize payment. Please retry.');
+        }
+
+        $draft->PAYLOAD = $storedPayload;
+        $draft->ORDER_SNAPSHOT = json_encode(['amount' => $amount, 'pv' => $pv, 'goods' => $goods], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+        $draft->PAY_AMOUNT = $amount;
+        $draft->CURRENCY = strtoupper($currency['CODE']);
+        $draft->PAYMENT_REFERENCE = $init['data']['reference'];
+        $draft->PAYMENT_NOTE = json_encode([
+            'authorization_url' => $init['data']['authorization_url'],
+            'access_code' => $init['data']['access_code'] ?? null,
+        ]);
+        $draft->STATUS = GuestDeclarationDraftStatus::PENDING_PAYMENT;
+        $draft->UPDATED_AT = time();
+        if (!$draft->save(false)) {
+            GuestDeclarationDraft::updateAll([
+                'STATUS' => GuestDeclarationDraftStatus::MANUAL_REVIEW,
+                'PAYLOAD' => $draft->PAYLOAD,
+                'ORDER_SNAPSHOT' => $draft->ORDER_SNAPSHOT,
+                'PAY_AMOUNT' => $draft->PAY_AMOUNT,
+                'CURRENCY' => $draft->CURRENCY,
+                'PAYMENT_REFERENCE' => $draft->PAYMENT_REFERENCE,
+                'PAYMENT_NOTE' => $draft->PAYMENT_NOTE,
+                'UPDATED_AT' => time(),
+            ], ['ID' => $draft->ID]);
+            throw new \RuntimeException('Payment started but its draft could not be saved; manual review is required.');
+        }
+        return $this->paymentResponse($draft);
+    }
+
+    private function paymentResponse(GuestDeclarationDraft $draft): array
+    {
+        $payment = json_decode((string) $draft->PAYMENT_NOTE, true) ?: [];
+        return [
+            'orderSn' => $draft->ORDER_SN,
+            'amount' => (float) $draft->PAY_AMOUNT,
+            'currency' => $draft->CURRENCY,
+            'reference' => $draft->PAYMENT_REFERENCE,
+            'authorizationUrl' => $payment['authorization_url'] ?? null,
+            'accessCode' => $payment['access_code'] ?? null,
+        ];
+    }
+
+    private function quote(array $input): array
+    {
+        $goods = [];
+        $amount = 0.0;
+        $pv = 0.0;
+        if (!empty($input['decWay']) && (string) $input['decWay'] === '1') {
+            $package = DeclarationPackage::findOneAsArray('ID=:ID AND STATUS=1', [':ID' => $input['packageId'] ?? null]);
+            if (!$package || (int) $package['STORE_NUMS'] <= 0) {
+                throw new BadRequestHttpException(Yii::t('app', 'productsDoesSoldOut'));
+            }
+            if ((string) ($input['decLv'] ?? '') !== (string) $package['LEVEL_ID']) {
+                throw new BadRequestHttpException(Yii::t('app', 'pleaseSelectTheEntryLevel'));
+            }
+            $amount = (float) $package['AMOUNT'];
+            $pv = (float) $package['PV'];
+            $goods[] = ['GOODS_ID' => $package['ID'], 'PRICE' => $amount, 'PV' => $pv, 'BUY_NUMS' => 1, 'SKU_CODE' => $package['PACKAGE_NO'], 'GOODS_TITLE' => $package['PACKAGE_NAME']];
+        } else {
+            $ids = $input['goodsId'] ?? [];
+            $quantities = $input['goodsNum'] ?? [];
+            if (!is_array($ids) || !is_array($quantities) || count($ids) !== count($quantities) || !$ids) {
+                throw new BadRequestHttpException('Select at least one valid product.');
+            }
+            foreach ($ids as $index => $id) {
+                $quantity = filter_var($quantities[$index], FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 99]]);
+                if ($quantity === false) {
+                    throw new BadRequestHttpException('Product quantity is invalid.');
+                }
+                if ($quantity === 0) {
+                    continue;
+                }
+                $product = ShopGoods::findOneAsArray('ID=:ID AND STATUS=1', [':ID' => $id]);
+                $nature = ShopGoodsNature::findOneAsArray('GOODS_ID=:GOODS_ID AND COUNTRY_ID=:COUNTRY_ID', [':GOODS_ID' => $id, ':COUNTRY_ID' => $input['countryId']]);
+                if (!$product || !$nature || (int) $product['STORE_NUMS'] < $quantity || !empty($product['INSTALMENT'])) {
+                    throw new BadRequestHttpException(Yii::t('app', 'productsDoesSoldOut'));
+                }
+                $unitPrice = (float) $nature['MARKET_PRICE'];
+                $unitPv = (float) $product['PRICE_PV'];
+                $amount += $unitPrice * $quantity;
+                $pv += $unitPv * $quantity;
+                $goods[] = ['GOODS_ID' => $product['ID'], 'PRICE' => $unitPrice, 'PV' => $unitPv, 'BUY_NUMS' => $quantity, 'SKU_CODE' => $product['GOODS_NO'], 'GOODS_TITLE' => $product['GOODS_NAME']];
+            }
+            if (!$goods) {
+                throw new BadRequestHttpException('Select at least one valid product.');
+            }
+        }
+        return [$amount, $pv, $goods];
+    }
+
+    public function completePaidDraft(string $orderSn, array $verifiedTransaction): array
+    {
+        $draft = GuestDeclarationDraft::findOne(['ORDER_SN' => $orderSn]);
+        if (!$draft) {
+            throw new NotFoundHttpException('Guest order draft was not found.');
+        }
+        if ($draft->STATUS === GuestDeclarationDraftStatus::COMPLETED) {
+            return ['status' => GuestDeclarationDraftStatus::COMPLETED, 'userId' => $draft->USER_ID];
+        }
+        if ($draft->STATUS === GuestDeclarationDraftStatus::MANUAL_REVIEW) {
+            return ['status' => GuestDeclarationDraftStatus::MANUAL_REVIEW];
+        }
+        $fields = $verifiedTransaction['metadata']['custom_fields'] ?? [];
+        $metadataMatches = ($fields[0]['value'] ?? null) === $draft->ORDER_SN
+            && ($fields[1]['value'] ?? null) === 'guestUserDec';
+        if ($draft->STATUS !== GuestDeclarationDraftStatus::PENDING_PAYMENT
+            && ($verifiedTransaction['status'] ?? null) === 'success'
+            && $metadataMatches
+        ) {
+            GuestDeclarationDraft::updateAll(
+                ['STATUS' => GuestDeclarationDraftStatus::MANUAL_REVIEW, 'UPDATED_AT' => time()],
+                ['ID' => $draft->ID]
+            );
+            return ['status' => GuestDeclarationDraftStatus::MANUAL_REVIEW];
+        }
+        $expectedAmount = (int) round(((float) $draft->PAY_AMOUNT) * 100);
+        if ($draft->STATUS !== GuestDeclarationDraftStatus::PENDING_PAYMENT
+            || ($verifiedTransaction['status'] ?? null) !== 'success'
+            || (string) ($verifiedTransaction['reference'] ?? '') !== (string) $draft->PAYMENT_REFERENCE
+            || (int) ($verifiedTransaction['amount'] ?? -1) !== $expectedAmount
+            || strtoupper((string) ($verifiedTransaction['currency'] ?? '')) !== strtoupper((string) $draft->CURRENCY)
+        ) {
+            if (($verifiedTransaction['reference'] ?? null) === $draft->PAYMENT_REFERENCE || $metadataMatches) {
+                GuestDeclarationDraft::updateAll(
+                    ['STATUS' => GuestDeclarationDraftStatus::MANUAL_REVIEW, 'UPDATED_AT' => time()],
+                    ['AND', ['ID' => $draft->ID], ['STATUS' => GuestDeclarationDraftStatus::PENDING_PAYMENT]]
+                );
+                return ['status' => GuestDeclarationDraftStatus::MANUAL_REVIEW];
+            }
+            throw new BadRequestHttpException('Verified payment does not match this guest order.');
+        }
+        if (!$metadataMatches) {
+            GuestDeclarationDraft::updateAll(
+                ['STATUS' => GuestDeclarationDraftStatus::MANUAL_REVIEW, 'UPDATED_AT' => time()],
+                ['AND', ['ID' => $draft->ID], ['STATUS' => GuestDeclarationDraftStatus::PENDING_PAYMENT]]
+            );
+            return ['status' => GuestDeclarationDraftStatus::MANUAL_REVIEW];
+        }
+        if ($draft->isExpired()) {
+            GuestDeclarationDraft::updateAll(
+                ['STATUS' => GuestDeclarationDraftStatus::MANUAL_REVIEW, 'UPDATED_AT' => time()],
+                ['AND', ['ID' => $draft->ID], ['STATUS' => GuestDeclarationDraftStatus::PENDING_PAYMENT]]
+            );
+            return ['status' => GuestDeclarationDraftStatus::MANUAL_REVIEW];
+        }
+
+        $transaction = Yii::$app->db->beginTransaction();
+        $claimed = false;
+        try {
+            if (!GuestDeclarationDraft::claimForProcessing($orderSn)) {
+                $draft->refresh();
+                if ($draft->STATUS === GuestDeclarationDraftStatus::COMPLETED) {
+                    $transaction->commit();
+                    return ['status' => GuestDeclarationDraftStatus::COMPLETED, 'userId' => $draft->USER_ID];
+                }
+                throw new \RuntimeException('Guest order is already being processed.');
+            }
+            $claimed = true;
+            $draft->refresh();
+            $payload = $draft->getPayload();
+            $snapshot = $draft->getOrderSnapshot();
+            $loop = new ApproachDeclarationLoopForm();
+            $loop->scenario = 'userDec';
+            $loop->isGuestDeclaration = true;
+            $loop->guestOrderSn = $orderSn;
+            $loop->data = [$payload];
+            $approachOrder = $loop->add();
+            if (!$approachOrder instanceof ApproachOrder) {
+                throw new \RuntimeException('Unable to create the paid registration order.');
+            }
+            if (abs((float) $approachOrder->ORDER_AMOUNT - (float) $snapshot['amount']) > 0.000001
+                || abs((float) $approachOrder->PV - (float) $snapshot['pv']) > 0.000001
+            ) {
+                throw new \RuntimeException('Order price changed after payment; manual review is required.');
+            }
+            if (isset($approachOrder->USER_NAME) && $approachOrder->USER_NAME !== $payload['insertUserName']) {
+                throw new \RuntimeException('Created member does not match the guest order.');
+            }
+
+            $verify = new ApproachOrderForm();
+            $verify->scenario = 'verifyPayStack';
+            $verify->sn = $orderSn;
+            $verify->status = Yii::$app->params['orderStatus']['paid']['value'];
+            $verify->note = [
+                'id' => $verifiedTransaction['id'] ?? null,
+                'domain' => $verifiedTransaction['domain'] ?? null,
+                'status' => $verifiedTransaction['status'],
+                'reference' => $verifiedTransaction['reference'],
+                'amount' => $verifiedTransaction['amount'],
+                'created_at' => $verifiedTransaction['created_at'] ?? null,
+                'paid_at' => $verifiedTransaction['paid_at'] ?? null,
+                'channel' => $verifiedTransaction['channel'] ?? null,
+                'currency' => $verifiedTransaction['currency'],
+                'metadata' => $verifiedTransaction['metadata'],
+                'email' => $verifiedTransaction['customer']['email'] ?? $verifiedTransaction['email'] ?? '',
+            ];
+            $verifiedOrder = $verify->verifyPayStack();
+            if (!$verifiedOrder) {
+                throw new \RuntimeException('Unable to finalize the paid registration order.');
+            }
+
+            $draft->USER_ID = $approachOrder->USER_ID;
+            $draft->PAYLOAD = json_encode(['insertUserName' => $payload['insertUserName']], JSON_UNESCAPED_UNICODE);
+        $draft->STATUS = GuestDeclarationDraftStatus::COMPLETED;
+            $draft->PAID_AT = time();
+            $draft->COMPLETED_AT = time();
+            $draft->UPDATED_AT = time();
+            if (!$draft->save(false)) {
+                throw new \RuntimeException('Unable to complete guest order draft.');
+            }
+            $transaction->commit();
+            return ['status' => GuestDeclarationDraftStatus::COMPLETED, 'userId' => $draft->USER_ID];
+        } catch (\Throwable $e) {
+            if ($transaction->isActive) {
+                $transaction->rollBack();
+            }
+            if ($claimed) {
+                GuestDeclarationDraft::updateAll(
+                    ['STATUS' => GuestDeclarationDraftStatus::MANUAL_REVIEW, 'UPDATED_AT' => time()],
+                    ['AND', ['ORDER_SN' => $orderSn], ['STATUS' => GuestDeclarationDraftStatus::PENDING_PAYMENT]]
+                );
+            }
+            throw $e;
+        }
+    }
+
+    public function markPaymentFailed(string $reference, array $verifiedTransaction): void
+    {
+        $draft = GuestDeclarationDraft::findOne(['PAYMENT_REFERENCE' => $reference]);
+        if (!$draft || $draft->STATUS !== GuestDeclarationDraftStatus::PENDING_PAYMENT
+            || ($verifiedTransaction['status'] ?? null) === 'success'
+            || (string) ($verifiedTransaction['reference'] ?? '') !== $reference
+        ) {
+            return;
+        }
+        GuestDeclarationDraft::updateAll(
+            ['STATUS' => GuestDeclarationDraftStatus::PAYMENT_FAILED, 'PAYMENT_REFERENCE' => null, 'PAYMENT_NOTE' => null, 'UPDATED_AT' => time()],
+            ['AND', ['ID' => $draft->ID], ['STATUS' => GuestDeclarationDraftStatus::PENDING_PAYMENT], ['PAYMENT_REFERENCE' => $reference]]
+        );
+    }
+
+    /**
+     * Require both unguessable draft id and its one-time bearer token.
+     */
+    private function loadAuthorizedDraft(string $draftId, string $accessToken): GuestDeclarationDraft
+    {
+        $draft = GuestDeclarationDraft::findOne(['ID' => $draftId]);
+        if (!$draft || !$draft->accessTokenMatches($accessToken)) {
+            throw new NotFoundHttpException('Guest order draft was not found.');
+        }
+        return $draft;
+    }
+}

+ 28 - 0
common/tests/standalone/guest-declaration-draft-status.php

@@ -0,0 +1,28 @@
+<?php
+
+require __DIR__ . '/../../helpers/GuestDeclarationDraftStatus.php';
+
+use common\helpers\GuestDeclarationDraftStatus;
+
+$checks = [
+    'draft can reserve payment initialization' => GuestDeclarationDraftStatus::canTransition('draft', 'payment_initializing'),
+    'payment initialization can become pending' => GuestDeclarationDraftStatus::canTransition('payment_initializing', 'pending_payment'),
+    'payment initialization can enter manual review' => GuestDeclarationDraftStatus::canTransition('payment_initializing', 'manual_review'),
+    'pending payment can be claimed' => GuestDeclarationDraftStatus::canTransition('pending_payment', 'processing'),
+    'pending payment can enter manual review' => GuestDeclarationDraftStatus::canTransition('pending_payment', 'manual_review'),
+    'failed payment can be retried' => GuestDeclarationDraftStatus::canTransition('payment_failed', 'payment_initializing'),
+    'processing can complete' => GuestDeclarationDraftStatus::canTransition('processing', 'completed'),
+    'completed cannot be reopened' => !GuestDeclarationDraftStatus::canTransition('completed', 'pending_payment'),
+    'pending payment can expire' => GuestDeclarationDraftStatus::isExpired(99, 100),
+    'expiry boundary is expired' => GuestDeclarationDraftStatus::isExpired(100, 100),
+    'future expiry is not expired' => !GuestDeclarationDraftStatus::isExpired(101, 100),
+];
+
+foreach ($checks as $description => $passed) {
+    if (!$passed) {
+        fwrite(STDERR, "FAIL: {$description}\n");
+        exit(1);
+    }
+}
+
+echo 'PASS: ' . count($checks) . " guest draft status checks\n";

+ 24 - 0
common/tests/standalone/paystack-webhook-signature.php

@@ -0,0 +1,24 @@
+<?php
+
+require __DIR__ . '/../../helpers/PayStack.php';
+
+use common\helpers\PayStack;
+
+$body = '{"event":"charge.success","data":{"reference":"OS123"}}';
+$secret = 'standalone-test-secret';
+$signature = hash_hmac('sha512', $body, $secret);
+$checks = [
+    'accepts the matching SHA512 signature' => PayStack::verifyWebhookSignature($body, $signature, $secret),
+    'rejects a different signature' => !PayStack::verifyWebhookSignature($body, str_repeat('0', 128), $secret),
+    'rejects an empty signature' => !PayStack::verifyWebhookSignature($body, '', $secret),
+    'signature binds to the raw request body' => !PayStack::verifyWebhookSignature($body . ' ', $signature, $secret),
+];
+
+foreach ($checks as $description => $passed) {
+    if (!$passed) {
+        fwrite(STDERR, "FAIL: {$description}\n");
+        exit(1);
+    }
+}
+
+echo 'PASS: ' . count($checks) . " PayStack signature checks\n";

+ 39 - 0
console/migrations/m261009_000001_create_guest_declaration_draft.php

@@ -0,0 +1,39 @@
+<?php
+
+use yii\db\Migration;
+
+class m261009_000001_create_guest_declaration_draft extends Migration
+{
+    public function safeUp()
+    {
+        $this->createTable('{{%GUEST_DECLARATION_DRAFT}}', [
+            'ID' => $this->string(32)->notNull(),
+            'ORDER_SN' => $this->string(32)->notNull(),
+            'ACCESS_TOKEN_HASH' => $this->string(64)->notNull(),
+            'STATUS' => $this->string(32)->notNull(),
+            'PAYLOAD' => $this->text()->notNull(),
+            'ORDER_SNAPSHOT' => $this->text()->notNull(),
+            'PAY_AMOUNT' => $this->decimal(20, 6)->notNull(),
+            'CURRENCY' => $this->string(8)->notNull(),
+            'PAYMENT_REFERENCE' => $this->string(128),
+            'PAYMENT_NOTE' => $this->text(),
+            'USER_ID' => $this->string(32),
+            'CREATED_AT' => $this->integer()->notNull(),
+            'UPDATED_AT' => $this->integer(),
+            'EXPIRES_AT' => $this->integer()->notNull(),
+            'PAID_AT' => $this->integer(),
+            'COMPLETED_AT' => $this->integer(),
+            'PRIMARY KEY ([[ID]])',
+        ]);
+
+        $this->createIndex('UX_GUEST_DRAFT_ORDER_SN', '{{%GUEST_DECLARATION_DRAFT}}', 'ORDER_SN', true);
+        $this->createIndex('UX_GUEST_DRAFT_TOKEN_HASH', '{{%GUEST_DECLARATION_DRAFT}}', 'ACCESS_TOKEN_HASH', true);
+        $this->createIndex('UX_GUEST_DRAFT_PAYMENT_REF', '{{%GUEST_DECLARATION_DRAFT}}', 'PAYMENT_REFERENCE', true);
+        $this->createIndex('IX_GUEST_DRAFT_STATUS_EXPIRY', '{{%GUEST_DECLARATION_DRAFT}}', ['STATUS', 'EXPIRES_AT']);
+    }
+
+    public function safeDown()
+    {
+        $this->dropTable('{{%GUEST_DECLARATION_DRAFT}}');
+    }
+}

+ 43 - 0
console/migrations/m261009_000002_allow_empty_declaration_payer.php

@@ -0,0 +1,43 @@
+<?php
+
+use yii\db\Migration;
+
+class m261009_000002_allow_empty_declaration_payer extends Migration
+{
+    public function safeUp()
+    {
+        $this->setPayerNullability(false);
+    }
+
+    public function safeDown()
+    {
+        $table = '{{%APPROACH_DEC_ORDER}}';
+        $nullCount = (int) (new \yii\db\Query())
+            ->from($table)
+            ->where(['USER_ID' => null])
+            ->count('*', $this->db);
+        if ($nullCount > 0) {
+            throw new \RuntimeException('Cannot restore APPROACH_DEC_ORDER.USER_ID NOT NULL while guest declaration rows exist.');
+        }
+
+        $this->setPayerNullability(true);
+    }
+
+    private function setPayerNullability(bool $notNull): void
+    {
+        $table = '{{%APPROACH_DEC_ORDER}}';
+        $schema = $this->db->getTableSchema($table, true);
+        if (!$schema || !isset($schema->columns['USER_ID'])) {
+            throw new \RuntimeException('APPROACH_DEC_ORDER.USER_ID column was not found.');
+        }
+
+        $column = $schema->columns['USER_ID'];
+        if ((bool) $column->allowNull === !$notNull) {
+            return;
+        }
+
+        $definition = $this->db->getSchema()->createColumnSchemaBuilder($column->type, $column->size);
+        $definition->notNull($notNull);
+        $this->alterColumn($table, 'USER_ID', $definition);
+    }
+}

+ 2 - 0
frontendApi/config/params.php

@@ -17,6 +17,8 @@ return [
         'v1/site/doc',
         'v1/site/config',
         'v1/shop/verify-approach-order',
+        'v1/user/guest-dec',
+        'v1/user/guest-dec-status',
         'v1/finance/prove-add',
         'v1/site/get-instance',
         'v1/site/languages',

+ 3 - 1
frontendApi/config/urlManagerRules.php

@@ -114,7 +114,9 @@ return [
             'GET rec-user' => 'rec-user',
             'GET bind' => 'bind',
             'GET,POST bind-edit/<id>' => 'bind-edit',
-            'GET,POST dec' => 'dec',
+            'GET,POST dec' => 'dec',
+            'GET,POST guest-dec' => 'guest-dec',
+            'GET guest-dec-status' => 'guest-dec-status',
             'GET,POST upgrade' => 'upgrade',
             'POST upgrade-info' => 'upgrade-info',
             'GET dec-package' => 'dec-package',

+ 41 - 21
frontendApi/modules/v1/controllers/ShopController.php

@@ -12,6 +12,7 @@ use common\helpers\Cache;
 use common\helpers\Date;
 use common\helpers\Form;
 use common\helpers\LoggerTool;
+use common\helpers\PayStack;
 use common\helpers\Tool;
 use common\helpers\user\Info;
 use common\models\ApproachDecOrder;
@@ -46,6 +47,7 @@ use common\models\ShopGoodsNature;
 use common\models\User;
 use common\models\UserBonus;
 use common\models\UserPerformance;
+use common\services\GuestDeclarationOrderService;
 use common\models\UserWallet;
 use Yii;
 use yii\base\Exception;
@@ -325,54 +327,72 @@ class ShopController extends BaseController {
      */
     public function actionVerifyApproachOrder() {
         if (\Yii::$app->request->isPost) {
-            $request = \Yii::$app->request->post();
-
-            LoggerTool::notice($request);
+            $rawBody = \Yii::$app->request->getRawBody();
+            $signature = (string) \Yii::$app->request->headers->get('x-paystack-signature', '');
+            if (!PayStack::verifyWebhookSignature($rawBody, $signature, PayStack::getSecretKey())) {
+                return http_response_code(401);
+            }
+            $request = json_decode($rawBody, true);
+            if (!is_array($request) || empty($request['data']) || empty($request['data']['reference'])) {
+                return http_response_code(400);
+            }
 
             // 支付webhook回调日志
             Tool::approachOrderCall($request);
 
             try {
-                $data = $request['data'];
-                $status = $request['data']['status'] ?? 'false';
+                $webhookData = $request['data'];
+                $verified = PayStack::transactionVerify((string) $webhookData['reference']);
+                if (empty($verified['status']) || empty($verified['data']) || !is_array($verified['data'])) {
+                    LoggerTool::error('PayStack transaction verification failed for reference ' . $webhookData['reference']);
+                    return http_response_code(500);
+                }
+                $data = $verified['data'];
+                $status = $data['status'] ?? 'false';
                 // 订单状态
                 $orderStatus = ($status == 'success') ? \Yii::$app->params['orderStatus']['paid']['value'] : \Yii::$app->params['orderStatus']['failPaid']['value'];
 
                 $customFields = $data['metadata']['custom_fields'] ?? [];
-                $oderSn = $customFields[0]['value'] ?? '';
+                $orderSn = $customFields[0]['value'] ?? '';
                 $orderType = $customFields[1]['value'] ?? false;
+                if ($orderType === 'guestUserDec') {
+                    if ($status !== 'success') {
+                        (new GuestDeclarationOrderService())->markPaymentFailed((string) $data['reference'], $data);
+                        return http_response_code(200);
+                    }
+                    (new GuestDeclarationOrderService())->completePaidDraft($orderSn, $data);
+                    return http_response_code(200);
+                }
+
                 $formModel = new ApproachOrderForm();
                 $formModel->scenario = 'verifyPayStack';
                 $load = [
-                    'sn' => $oderSn,
+                    'sn' => $orderSn,
                     'scenario' => 'verifyPayStack',
                     'status' => $orderStatus,
                     'note' => [
-                        'id' => $data['id'],
-                        'domain' => $data['domain'],
-                        'status' => $data['status'],
+                        'id' => $data['id'] ?? null,
+                        'domain' => $data['domain'] ?? null,
+                        'status' => $status,
                         'reference' => $data['reference'],
-                        'amount' => $data['amount'],
-                        'created_at' => $data['created_at'],
-                        'paid_at' => $data['paid_at'],
-                        'channel' => $data['channel'],
-                        'currency' => $data['currency'],
-                        'ip_address' => $data['ip_address'],
-                        'metadata' => $data['metadata'],
-                        'customer' => $data['customer'],
+                        'amount' => $data['amount'] ?? null,
+                        'created_at' => $data['created_at'] ?? null,
+                        'paid_at' => $data['paid_at'] ?? null,
+                        'channel' => $data['channel'] ?? null,
+                        'currency' => $data['currency'] ?? null,
+                        'metadata' => $data['metadata'] ?? [],
                         'email' => $data['customer']['email'] ?? '',
                     ],
                 ];
 
                 if ($formModel->load($load, '') && $order = $formModel->verifyPayStack()) {
-                    LoggerTool::error($order);
                     return http_response_code(200);
                 } else {
                     LoggerTool::error(Form::formatErrorsForApi($formModel->getErrors()));
                     return http_response_code(500);
                 }
-            } catch (\Exception $e) {
-                LoggerTool::error(sprintf('actionVerifyApproachOrderError: File[%s], Line:[%s], Message[%s]', $e->getFile(), $e->getLine(), $e->getMessage()));
+            } catch (\Throwable $e) {
+                LoggerTool::error('actionVerifyApproachOrder failed: ' . get_class($e));
                 return http_response_code(500);
             }
         }

+ 64 - 0
frontendApi/modules/v1/controllers/UserController.php

@@ -41,6 +41,7 @@ use common\models\ReceiveAddress;
 use common\models\Region;
 use common\models\ShopGoods;
 use common\models\ShopGoodsNature;
+use common\services\GuestDeclarationOrderService;
 use common\models\UpgradeType;
 use common\models\User;
 use common\models\UserBind;
@@ -515,6 +516,69 @@ class UserController extends BaseController {
     /**
      * 报单管理
      */
+    public function actionGuestDec()
+    {
+        $service = new GuestDeclarationOrderService();
+        try {
+            if (Yii::$app->request->isPost) {
+                $input = Yii::$app->request->post();
+                $result = $service->submitDraft(
+                    (string) ($input['draftId'] ?? ''),
+                    (string) ($input['accessToken'] ?? ''),
+                    $input
+                );
+                return static::notice($result);
+            }
+
+            $countryId = (string) Yii::$app->request->get('countryId', '');
+            if ($countryId === '') {
+                return static::notice(Yii::t('app', 'countryDoesNotSelect'), 400);
+            }
+            $draft = $service->createDraft($countryId);
+            $decLevels = Cache::getDecLevelConfig();
+            $packages = DeclarationPackage::getAllData();
+            foreach ($packages as $index => $package) {
+                $packages[$index]['LEVEL_NAME'] = $decLevels[$package['LEVEL_ID']]['LEVEL_NAME'] ?? '';
+            }
+            $goods = ShopGoods::find()
+                ->alias('S')
+                ->select('S.*,N.MARKET_PRICE,N.SELL_PRICE,N.TAX_RATE,N.LOCAL_CURRENCY_ID,N.COUNTRY_ID,C.CODE')
+                ->innerJoin(ShopGoodsNature::tableName() . ' N', 'N.GOODS_ID=S.ID')
+                ->innerJoin(Currency::tableName() . ' C', 'C.ID=N.LOCAL_CURRENCY_ID')
+                ->where('S.STATUS=1 AND S.CATEGORY_TYPE=1 AND FIND_IN_SET(1,S.GIFT_TYPE)>0 AND N.COUNTRY_ID=:COUNTRY_ID', [':COUNTRY_ID' => $countryId])
+                ->orderBy('S.SORT ASC')
+                ->asArray()->all();
+
+            return static::notice([
+                'draft' => $draft,
+                'allDecPackage' => $packages,
+                'allGoods' => $goods,
+                'region' => Region::getByCountryId($countryId),
+                'payList' => ['pay_stack' => ['name' => 'PayStack']],
+                'decLevel' => DeclarationLevel::getData(),
+            ]);
+        } catch (\Throwable $e) {
+            LoggerTool::error('guestDec failed: ' . get_class($e));
+            if ($e instanceof \yii\web\HttpException) {
+                return static::notice($e->getMessage(), $e->statusCode);
+            }
+            return static::notice('Unable to process guest order.', 500);
+        }
+    }
+
+    public function actionGuestDecStatus()
+    {
+        try {
+            $result = (new GuestDeclarationOrderService())->getDraftStatus(
+                (string) Yii::$app->request->get('draftId', ''),
+                (string) Yii::$app->request->get('accessToken', '')
+            );
+            return static::notice($result);
+        } catch (\Throwable $e) {
+            return static::notice('Guest order draft was not found.', 404);
+        }
+    }
+
     public function actionDec() {
         $redis = \Yii::$app->redis;
         $post = \Yii::$app->request->post();