diff --git a/backend/app/Http/Controllers/API/V0_3/ReportGiftController.php b/backend/app/Http/Controllers/API/V0_3/ReportGiftController.php new file mode 100644 index 000000000..002da0ea6 --- /dev/null +++ b/backend/app/Http/Controllers/API/V0_3/ReportGiftController.php @@ -0,0 +1,170 @@ +gifts->createRequest( + $this->orgContext->orgId(), + $this->userId(), + $this->anonId($request), + $id + ); + + return $this->respond($result, 201); + } + + public function showOwner(Request $request, string $id, string $gift_request_id): JsonResponse + { + return $this->respond($this->gifts->showOwner( + $this->orgContext->orgId(), + $this->userId(), + $this->anonId($request), + $id, + $gift_request_id + )); + } + + public function cancel(Request $request, string $id, string $gift_request_id): JsonResponse + { + return $this->respond($this->gifts->cancel( + $this->orgContext->orgId(), + $this->userId(), + $this->anonId($request), + $id, + $gift_request_id + )); + } + + public function showPublic(string $token): JsonResponse + { + return $this->respond($this->gifts->showPublic($token, $this->orgContext->orgId())); + } + + public function purchaseWechatMiniVirtual(Request $request, string $token): JsonResponse + { + $payload = $request->validate([ + 'idempotency_key' => ['nullable', 'string', 'max:128'], + 'wx_login_code' => ['required', 'string', 'max:128'], + 'target_attempt_id' => ['prohibited'], + 'sku' => ['prohibited'], + 'amount_cents' => ['prohibited'], + 'currency' => ['prohibited'], + 'goodsPrice' => ['prohibited'], + 'org_id' => ['prohibited'], + 'user_id' => ['prohibited'], + 'anon_id' => ['prohibited'], + ]); + $idempotencyKey = trim((string) ($request->header('Idempotency-Key') + ?: ($payload['idempotency_key'] ?? ''))); + + $reserved = $this->gifts->reserveWechatMiniVirtualOrder( + $token, + $this->orgContext->orgId(), + $this->userId(), + $this->anonId($request), + $idempotencyKey, + $this->requestId($request) + ); + if (($reserved['ok'] ?? false) !== true || ! is_object($reserved['order'] ?? null)) { + return $this->respond($reserved); + } + + $payment = $this->gifts->createWechatMiniVirtualPaymentAction( + $reserved['order'], + (string) $payload['wx_login_code'] + ); + if (($payment['ok'] ?? false) !== true) { + $this->gifts->releaseUnpayableReservationForOrder($reserved['order']); + + return $this->respond($payment); + } + + return response()->json([ + 'ok' => true, + 'order_no' => (string) $reserved['order_no'], + 'pay' => $payment['pay'] ?? null, + 'idempotent' => (bool) ($reserved['idempotent'] ?? false), + ]); + } + + public function getOrder(Request $request, string $order_no): JsonResponse + { + $response = app(CommerceController::class)->getOrder($request, $order_no); + if ($response->getStatusCode() !== 200) { + return $response; + } + + $payload = $response->getData(true); + if (! is_array($payload)) { + return $response; + } + + return response()->json( + $this->gifts->presentOwnedOrderPayload( + $this->orgContext->orgId(), + $order_no, + $payload + ), + $response->getStatusCode() + ); + } + + /** + * @param array $result + */ + private function respond(array $result, int $successStatus = 200): JsonResponse + { + return response()->json( + $result, + ($result['ok'] ?? false) === true ? $successStatus : (int) ($result['status'] ?? 400) + ); + } + + private function userId(): ?string + { + $userId = $this->orgContext->userId(); + + return $userId !== null ? (string) $userId : null; + } + + private function anonId(Request $request): ?string + { + foreach ([ + $this->orgContext->anonId(), + $request->attributes->get('anon_id'), + $request->attributes->get('fm_anon_id'), + $request->attributes->get('client_anon_id'), + ] as $candidate) { + $value = trim((string) $candidate); + if ($value !== '') { + return $value; + } + } + + return null; + } + + private function requestId(Request $request): ?string + { + $requestId = trim((string) ($request->header('X-Request-Id') + ?: $request->header('X-Request-ID', ''))); + + return $requestId !== '' ? substr($requestId, 0, 128) : null; + } +} diff --git a/backend/app/Services/Commerce/EntitlementManager.php b/backend/app/Services/Commerce/EntitlementManager.php index ab4781fa7..257558d2b 100644 --- a/backend/app/Services/Commerce/EntitlementManager.php +++ b/backend/app/Services/Commerce/EntitlementManager.php @@ -8,6 +8,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; class EntitlementManager @@ -536,7 +537,97 @@ public function revokeByOrderNo(int $orgId, string $orderNo): array ]; } + $isActualRefund = strtolower(trim((string) ($order->payment_state ?? $order->status ?? ''))) === 'refunded'; + $isGiftOrder = Schema::hasTable('report_gift_requests') + && DB::table('report_gift_requests') + ->where('purchased_order_id', (string) ($order->id ?? '')) + ->exists(); + if ($isGiftOrder && $isActualRefund) { + DB::table('report_gift_requests') + ->where('purchased_order_id', (string) ($order->id ?? '')) + ->whereIn('status', ['purchasing', 'fulfilled']) + ->update([ + 'status' => 'refunded', + 'updated_at' => now(), + ]); + } + $now = now(); + $directGrant = DB::table('benefit_grants') + ->where('org_id', $orderOrgId) + ->where('order_no', $orderNo) + ->where('status', 'active') + ->lockForUpdate() + ->first(); + $fallbackGift = $directGrant !== null && Schema::hasTable('report_gift_requests') + ? DB::table('report_gift_requests as gifts') + ->join('orders as gift_orders', 'gift_orders.id', '=', 'gifts.purchased_order_id') + ->where('gifts.org_id', $orderOrgId) + ->where('gifts.target_attempt_id', $attemptId) + ->where('gifts.status', 'fulfilled') + ->where('gifts.purchased_order_id', '<>', (string) ($order->id ?? '')) + ->where(function ($query) { + $query->whereNull('gift_orders.payment_state') + ->orWhereNotIn('gift_orders.payment_state', ['refunded', 'canceled', 'expired', 'failed']); + }) + ->where(function ($query) { + $query->whereNull('gift_orders.grant_state') + ->orWhere('gift_orders.grant_state', '<>', 'revoked'); + }) + ->select([ + 'gifts.id as gift_request_id', + 'gifts.recipient_user_id', + 'gifts.recipient_anon_id', + 'gift_orders.id as order_id', + 'gift_orders.order_no', + ]) + ->orderByDesc('gifts.fulfilled_at') + ->lockForUpdate() + ->first() + : null; + if ($directGrant !== null && $fallbackGift !== null) { + $meta = $this->decodeMeta($directGrant->meta_json ?? null); + $meta['unlock_source'] = ReportAccess::UNLOCK_SOURCE_GIFT_PURCHASE; + $meta['granted_via'] = ReportAccess::UNLOCK_SOURCE_GIFT_PURCHASE; + $meta['gift_request_id'] = (string) $fallbackGift->gift_request_id; + + DB::table('benefit_grants') + ->where('id', (string) $directGrant->id) + ->update([ + 'user_id' => trim((string) ($fallbackGift->recipient_user_id ?? '')) + ?: trim((string) ($fallbackGift->recipient_anon_id ?? '')) + ?: 'attempt:'.$attemptId, + 'benefit_ref' => trim((string) ($fallbackGift->recipient_anon_id ?? '')) + ?: trim((string) ($fallbackGift->recipient_user_id ?? '')) + ?: 'attempt:'.$attemptId, + 'order_no' => (string) $fallbackGift->order_no, + 'source_order_id' => (string) $fallbackGift->order_id, + 'expires_at' => null, + 'meta_json' => json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'updated_at' => $now, + 'revoked_at' => null, + ]); + $this->orders->syncGrantState($orderNo, $orderOrgId, 'revoked'); + $this->orders->syncGrantState((string) $fallbackGift->order_no, $orderOrgId, 'granted'); + $this->refreshAccessProjection($orderOrgId, $attemptId, [ + 'source_system' => 'entitlement_manager', + 'source_ref' => (string) $fallbackGift->order_no, + 'actor_type' => trim((string) ($fallbackGift->recipient_user_id ?? '')) !== '' ? 'user' : 'anon', + 'actor_id' => trim((string) ($fallbackGift->recipient_user_id ?? '')) + ?: trim((string) ($fallbackGift->recipient_anon_id ?? '')), + 'reason_code' => 'entitlement_source_rebound', + 'org_id' => $orderOrgId, + ]); + + return [ + 'ok' => true, + 'revoked' => 0, + 'benefit_code' => $benefitCode, + 'attempt_id' => $attemptId, + 'rebound_to_gift_request_id' => (string) $fallbackGift->gift_request_id, + ]; + } + $byOrderNo = DB::table('benefit_grants') ->where('org_id', $orderOrgId) ->where('order_no', $orderNo) @@ -558,6 +649,18 @@ public function revokeByOrderNo(int $orgId, string $orderNo): array ]; } + if ($isGiftOrder) { + $this->orders->syncGrantState($orderNo, $orderOrgId, 'revoked'); + + return [ + 'ok' => true, + 'revoked' => 0, + 'benefit_code' => $benefitCode, + 'attempt_id' => $attemptId, + 'gift_order_without_direct_grant' => true, + ]; + } + $revoked = DB::table('benefit_grants') ->where('org_id', $orderOrgId) ->where('benefit_code', $benefitCode) diff --git a/backend/app/Services/Commerce/Repair/OrderRepairService.php b/backend/app/Services/Commerce/Repair/OrderRepairService.php index a830fd656..d32e18a67 100644 --- a/backend/app/Services/Commerce/Repair/OrderRepairService.php +++ b/backend/app/Services/Commerce/Repair/OrderRepairService.php @@ -7,6 +7,7 @@ use App\Models\Order; use App\Services\Commerce\EntitlementManager; use App\Services\Commerce\OrderManager; +use App\Services\Commerce\ReportGiftService; use App\Services\Commerce\SkuCatalog; use Illuminate\Support\Facades\DB; @@ -30,7 +31,20 @@ public function __construct( */ public function repairPaidOrder(object $order, array $context = []): array { - $freshOrder = $this->reloadOrder($order); + return DB::transaction( + fn (): array => $this->repairPaidOrderLocked($order, $context), + 3 + ); + } + + /** + * @param Order|object $order + * @param array $context + * @return array + */ + private function repairPaidOrderLocked(object $order, array $context): array + { + $freshOrder = $this->reloadOrder($order, true); if ($freshOrder === null) { return $this->failure('ORDER_NOT_FOUND', 'order not found.', $order, $context); } @@ -39,6 +53,14 @@ public function repairPaidOrder(object $order, array $context = []): array if ($paymentState !== Order::PAYMENT_STATE_PAID) { return $this->skip('PAYMENT_NOT_PAID', 'order payment_state is not paid.', $freshOrder, $context); } + if ($this->isIntentionallyRevoked($freshOrder)) { + return $this->skip( + 'GRANT_INTENTIONALLY_REVOKED', + 'order grant was intentionally revoked.', + $freshOrder, + $context + ); + } $skuRow = $this->resolveSkuRow($freshOrder); if ($skuRow === null) { @@ -66,11 +88,27 @@ public function repairPaidOrder(object $order, array $context = []): array } $attemptMeta = $this->resolveAttemptMeta((int) $freshOrder->org_id, $attemptId); - $ownerGuard = $this->validateAttemptOwnershipForOrder($freshOrder, $attemptMeta); - if (! ($ownerGuard['ok'] ?? false)) { + $giftService = app(ReportGiftService::class); + $giftRequest = $giftService->findBoundGiftForOrder($freshOrder, true); + if ($giftRequest !== null) { + $restored = $giftService->restoreTerminalGiftForVerifiedPayment($giftRequest, $freshOrder); + if (! ($restored['ok'] ?? false)) { + return $this->failure( + (string) ($restored['error'] ?? 'GIFT_REQUEST_NOT_PAYABLE'), + (string) ($restored['message'] ?? 'gift request is not payable.'), + $freshOrder, + $context + ); + } + $giftRequest = $giftService->findBoundGiftForOrder($freshOrder, true); + } + $ownershipGuard = $giftRequest !== null + ? $giftService->validateBoundGiftOrder($giftRequest, $freshOrder) + : $this->validateAttemptOwnershipForOrder($freshOrder, $attemptMeta); + if (! ($ownershipGuard['ok'] ?? false)) { return $this->failure( - (string) ($ownerGuard['error'] ?? 'ATTEMPT_OWNER_MISMATCH'), - (string) ($ownerGuard['message'] ?? 'order owner mismatch.'), + (string) ($ownershipGuard['error'] ?? 'ATTEMPT_OWNER_MISMATCH'), + (string) ($ownershipGuard['message'] ?? 'order owner mismatch.'), $freshOrder, $context, [ @@ -109,8 +147,8 @@ public function repairPaidOrder(object $order, array $context = []): array ); } - $activeGrant = $this->resolveActiveGrantForOrder($freshOrder, $benefitCode); - if ($activeGrant !== null) { + $activeGrant = $this->resolveActiveGrantForOrder($freshOrder, $benefitCode, true); + if ($activeGrant !== null && $giftRequest === null) { $this->orders->syncGrantState((string) $freshOrder->order_no, (int) $freshOrder->org_id, Order::GRANT_STATE_GRANTED); $transition = $this->ensureFulfilled($freshOrder); if (! ($transition['ok'] ?? false)) { @@ -146,17 +184,26 @@ public function repairPaidOrder(object $order, array $context = []): array } [$scopeOverride, $expiresAt, $modulesIncluded] = $this->resolveGrantOptions($skuRow); - $grant = $this->entitlements->grantAttemptUnlock( - (int) $freshOrder->org_id, - $freshOrder->user_id ? (string) $freshOrder->user_id : null, - $freshOrder->anon_id ? (string) $freshOrder->anon_id : null, - $benefitCode, - $attemptId, - (string) $freshOrder->order_no, - $scopeOverride, - $expiresAt, - $modulesIncluded - ); + $grant = $giftRequest !== null + ? $giftService->grantVerifiedPaidGift( + $giftRequest, + $freshOrder, + $benefitCode, + $scopeOverride, + $expiresAt, + $modulesIncluded ?? [] + ) + : $this->entitlements->grantAttemptUnlock( + (int) $freshOrder->org_id, + $freshOrder->user_id ? (string) $freshOrder->user_id : null, + $freshOrder->anon_id ? (string) $freshOrder->anon_id : null, + $benefitCode, + $attemptId, + (string) $freshOrder->order_no, + $scopeOverride, + $expiresAt, + $modulesIncluded + ); if (! ($grant['ok'] ?? false)) { $this->orders->syncGrantState((string) $freshOrder->order_no, (int) $freshOrder->org_id, Order::GRANT_STATE_GRANT_FAILED); @@ -218,8 +265,11 @@ public function hasActiveGrantForOrder(object $order): bool /** * @param Order|object $order */ - public function resolveActiveGrantForOrder(object $order, ?string $benefitCode = null): ?object - { + public function resolveActiveGrantForOrder( + object $order, + ?string $benefitCode = null, + bool $lockForUpdate = false + ): ?object { $orgId = (int) ($order->org_id ?? 0); $orderNo = trim((string) ($order->order_no ?? '')); $attemptId = trim((string) ($order->target_attempt_id ?? '')); @@ -232,6 +282,10 @@ public function resolveActiveGrantForOrder(object $order, ?string $benefitCode = $query = DB::table('benefit_grants') ->where('org_id', $orgId) ->where('status', 'active') + ->where(function ($query): void { + $query->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }) ->where(function ($query) use ($orderNo, $attemptId): void { $applied = false; if ($orderNo !== '') { @@ -251,6 +305,9 @@ public function resolveActiveGrantForOrder(object $order, ?string $benefitCode = if ($benefitCode !== '') { $query->where('benefit_code', $benefitCode); } + if ($lockForUpdate) { + $query->lockForUpdate(); + } return $query->first(); } @@ -268,6 +325,9 @@ public function requiresPaidOrderRepair(object $order): bool if (! $this->isPaidReportUnlockOrder($freshOrder)) { return false; } + if ($this->isIntentionallyRevoked($freshOrder)) { + return false; + } if ($this->resolveBlockingSemanticRejectEvent($freshOrder) !== null) { return false; @@ -415,7 +475,7 @@ private function emptyAttemptMeta(): array /** * @param Order|object $order */ - private function reloadOrder(object $order): ?object + private function reloadOrder(object $order, bool $lockForUpdate = false): ?object { $orgId = (int) ($order->org_id ?? 0); $orderNo = trim((string) ($order->order_no ?? '')); @@ -426,16 +486,29 @@ private function reloadOrder(object $order): ?object return null; } - return DB::table('orders') + $query = DB::table('orders') ->where('id', $id) - ->when($orgId > 0 || isset($order->org_id), fn ($query) => $query->where('org_id', $orgId)) - ->first(); + ->when($orgId > 0 || isset($order->org_id), fn ($query) => $query->where('org_id', $orgId)); + if ($lockForUpdate) { + $query->lockForUpdate(); + } + + return $query->first(); } - return DB::table('orders') + $query = DB::table('orders') ->where('order_no', $orderNo) - ->where('org_id', $orgId) - ->first(); + ->where('org_id', $orgId); + if ($lockForUpdate) { + $query->lockForUpdate(); + } + + return $query->first(); + } + + private function isIntentionallyRevoked(object $order): bool + { + return strtolower(trim((string) ($order->grant_state ?? ''))) === Order::GRANT_STATE_REVOKED; } private function resolveSkuRow(object $order): ?object diff --git a/backend/app/Services/Commerce/ReportGiftService.php b/backend/app/Services/Commerce/ReportGiftService.php new file mode 100644 index 000000000..fd09aa885 --- /dev/null +++ b/backend/app/Services/Commerce/ReportGiftService.php @@ -0,0 +1,1026 @@ + + */ + public function createRequest( + int $orgId, + ?string $userId, + ?string $anonId, + string $attemptId + ): array { + if (! $this->giftAvailable() || ! $this->paymentProviders->isEnabled(self::PROVIDER)) { + return $this->error('GIFT_UNAVAILABLE', 'report gifting is unavailable.', 403); + } + + $attemptId = trim($attemptId); + + return DB::transaction(function () use ($orgId, $userId, $anonId, $attemptId): array { + return $this->createRequestLocked($orgId, $userId, $anonId, $attemptId); + }); + } + + /** + * @return array + */ + private function createRequestLocked( + int $orgId, + ?string $userId, + ?string $anonId, + string $attemptId + ): array { + $attempt = DB::table('attempts') + ->where('org_id', $orgId) + ->where('id', $attemptId) + ->lockForUpdate() + ->first(); + if (! $attempt) { + return $this->error('ATTEMPT_NOT_FOUND', 'attempt not found.', 404); + } + if (! $this->actorOwnsAttempt($attempt, $userId, $anonId)) { + return $this->error('ATTEMPT_OWNER_MISMATCH', 'attempt owner mismatch.', 403); + } + + $scaleCode = strtoupper(trim((string) ($attempt->scale_code ?? ''))); + if ($scaleCode === ReportAccess::SCALE_IQ_RAVEN) { + return $this->error('IQ_GIFT_DISABLED', 'IQ report gifting is unavailable.', 403); + } + if ($scaleCode !== ReportAccess::SCALE_MBTI) { + return $this->error('ROLLOUT_DISABLED', 'report gifting is unavailable for this scale.', 403); + } + + $sku = $this->skuForScale($scaleCode); + $skuGuard = $this->validateSku($orgId, $sku); + if (($skuGuard['ok'] ?? false) !== true) { + return $skuGuard; + } + + $eligibility = app(WechatMiniVirtualPaymentService::class)->validateOrderEligibility( + $orgId, + $this->normalizeActorId($attempt->user_id ?? null), + $this->normalizeActorId($attempt->anon_id ?? null), + $attemptId, + $sku, + 1 + ); + if (($eligibility['ok'] ?? false) !== true) { + return $eligibility; + } + + $existing = DB::table('report_gift_requests') + ->where('org_id', $orgId) + ->where('target_attempt_id', $attemptId) + ->where(function ($query) { + $query->where('status', self::STATUS_PURCHASING) + ->orWhere(function ($pending) { + $pending->where('status', self::STATUS_PENDING) + ->where('expires_at', '>', now()); + }); + }) + ->first(); + if ($existing) { + return $this->error('GIFT_REQUEST_ACTIVE', 'an active gift request already exists.', 409); + } + + $token = $this->generateToken(); + $now = now(); + $expiresAt = $now->copy()->addHours($this->ttlHours()); + $id = (string) Str::uuid(); + + try { + DB::table('report_gift_requests')->insert([ + 'id' => $id, + 'public_token_hash' => $this->tokenHash($token), + 'org_id' => $orgId, + 'target_attempt_id' => $attemptId, + 'recipient_user_id' => $this->normalizeActorId($attempt->user_id ?? null), + 'recipient_anon_id' => $this->normalizeActorId($attempt->anon_id ?? null), + 'scale_code' => $scaleCode, + 'sku' => $sku, + 'status' => self::STATUS_PENDING, + 'expires_at' => $expiresAt, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } catch (QueryException) { + return $this->error('GIFT_REQUEST_CREATE_FAILED', 'gift request could not be created.', 409); + } + + return [ + 'ok' => true, + 'gift_request' => [ + 'id' => $id, + 'public_token' => $token, + 'status' => self::STATUS_PENDING, + 'expires_at' => $expiresAt->toISOString(), + 'scale_code' => $scaleCode, + 'sku' => $sku, + ], + ]; + } + + /** + * @return array + */ + public function showPublic(string $token, int $orgId): array + { + $gift = $this->findByToken($token, $orgId); + if ($gift === null) { + return $this->error('GIFT_REQUEST_NOT_FOUND', 'gift request not found.', 404); + } + + return [ + 'ok' => true, + 'gift' => $this->publicSummary($gift), + ]; + } + + /** + * @return array + */ + public function showOwner( + int $orgId, + ?string $userId, + ?string $anonId, + string $attemptId, + string $giftRequestId + ): array { + $gift = DB::table('report_gift_requests') + ->where('id', trim($giftRequestId)) + ->where('org_id', $orgId) + ->where('target_attempt_id', trim($attemptId)) + ->first(); + if ($gift === null) { + return $this->error('GIFT_REQUEST_NOT_FOUND', 'gift request not found.', 404); + } + if (! $this->actorMatchesRecipient($gift, $userId, $anonId)) { + return $this->error('GIFT_REQUEST_FORBIDDEN', 'gift request is not accessible.', 403); + } + + return [ + 'ok' => true, + 'gift_request' => [ + 'id' => (string) $gift->id, + 'status' => $this->effectiveStatus($gift), + 'expires_at' => (string) $gift->expires_at, + 'scale_code' => (string) $gift->scale_code, + 'sku' => (string) $gift->sku, + 'fulfilled_at' => $gift->fulfilled_at !== null ? (string) $gift->fulfilled_at : null, + 'canceled_at' => $gift->canceled_at !== null ? (string) $gift->canceled_at : null, + ], + ]; + } + + /** + * @return array + */ + public function cancel( + int $orgId, + ?string $userId, + ?string $anonId, + string $attemptId, + string $giftRequestId + ): array { + return DB::transaction(function () use ($orgId, $userId, $anonId, $attemptId, $giftRequestId): array { + $gift = DB::table('report_gift_requests') + ->where('id', trim($giftRequestId)) + ->where('org_id', $orgId) + ->where('target_attempt_id', trim($attemptId)) + ->lockForUpdate() + ->first(); + if ($gift === null) { + return $this->error('GIFT_REQUEST_NOT_FOUND', 'gift request not found.', 404); + } + if (! $this->actorMatchesRecipient($gift, $userId, $anonId)) { + return $this->error('GIFT_REQUEST_FORBIDDEN', 'gift request is not accessible.', 403); + } + if ($this->effectiveStatus($gift) !== self::STATUS_PENDING || $gift->purchased_order_id !== null) { + return $this->error('GIFT_REQUEST_NOT_CANCELABLE', 'gift request can no longer be canceled.', 409); + } + + DB::table('report_gift_requests') + ->where('id', (string) $gift->id) + ->update([ + 'status' => self::STATUS_CANCELED, + 'canceled_at' => now(), + 'updated_at' => now(), + ]); + + return [ + 'ok' => true, + 'gift_request' => [ + 'id' => (string) $gift->id, + 'status' => self::STATUS_CANCELED, + ], + ]; + }); + } + + /** + * @return array + */ + public function reserveWechatMiniVirtualOrder( + string $token, + int $orgId, + ?string $payerUserId, + ?string $payerAnonId, + string $idempotencyKey, + ?string $requestId = null + ): array { + if (! $this->giftAvailable() || ! $this->paymentProviders->isEnabled(self::PROVIDER)) { + return $this->error('GIFT_PAYMENT_UNAVAILABLE', 'gift payment is unavailable.', 403); + } + if ($this->normalizeActorId($payerUserId) === null && $this->normalizeActorId($payerAnonId) === null) { + return $this->error('PAYER_IDENTITY_REQUIRED', 'payer identity is required.', 401); + } + $idempotencyKey = trim($idempotencyKey); + if ($idempotencyKey === '' || strlen($idempotencyKey) > 128) { + return $this->error('IDEMPOTENCY_KEY_REQUIRED', 'idempotency key is required.', 422); + } + + return DB::transaction(function () use ( + $token, + $orgId, + $payerUserId, + $payerAnonId, + $idempotencyKey, + $requestId + ): array { + $gift = DB::table('report_gift_requests') + ->where('public_token_hash', $this->tokenHash($token)) + ->where('org_id', $orgId) + ->lockForUpdate() + ->first(); + if ($gift === null) { + return $this->error('GIFT_REQUEST_NOT_FOUND', 'gift request not found.', 404); + } + + $status = $this->effectiveStatus($gift); + if ($status === self::STATUS_EXPIRED) { + return $this->error('GIFT_REQUEST_EXPIRED', 'gift request has expired.', 410); + } + if ($status === self::STATUS_CANCELED) { + return $this->error('GIFT_REQUEST_CANCELED', 'gift request was canceled.', 409); + } + if (in_array($status, [self::STATUS_FULFILLED, self::STATUS_REFUNDED], true)) { + return $this->error('GIFT_REQUEST_CLOSED', 'gift request is closed.', 409); + } + + if ($gift->purchased_order_id !== null) { + if (! $this->actorMatchesPurchaser($gift, $payerUserId, $payerAnonId)) { + return $this->error('GIFT_REQUEST_ALREADY_RESERVED', 'gift request already has a purchaser.', 409); + } + $order = DB::table('orders')->where('id', (string) $gift->purchased_order_id)->first(); + if ($order === null) { + return $this->error('GIFT_ORDER_NOT_FOUND', 'gift order not found.', 409); + } + + return [ + 'ok' => true, + 'order' => $order, + 'order_no' => (string) $order->order_no, + 'idempotent' => true, + ]; + } + + $eligibility = $this->wechatMiniVirtual->validateOrderEligibility( + $orgId, + $this->normalizeActorId($gift->recipient_user_id ?? null), + $this->normalizeActorId($gift->recipient_anon_id ?? null), + (string) $gift->target_attempt_id, + (string) $gift->sku, + 1 + ); + if (($eligibility['ok'] ?? false) !== true) { + return $eligibility; + } + + $orderResult = $this->orders->createOrder( + $orgId, + $this->normalizeActorId($payerUserId), + $this->normalizeActorId($payerAnonId), + (string) $gift->sku, + 1, + (string) $gift->target_attempt_id, + self::PROVIDER, + $this->giftIdempotencyKey( + (string) $gift->id, + $idempotencyKey, + $this->normalizeActorId($payerUserId), + $this->normalizeActorId($payerAnonId), + $this->giftOrderReservationGeneration($gift, $payerUserId, $payerAnonId) + ), + null, + $requestId + ); + if (($orderResult['ok'] ?? false) !== true || ! is_object($orderResult['order'] ?? null)) { + return $this->error( + (string) ($orderResult['error_code'] ?? $orderResult['error'] ?? 'GIFT_ORDER_CREATE_FAILED'), + (string) ($orderResult['message'] ?? 'gift order could not be created.'), + 409 + ); + } + + $order = $orderResult['order']; + $updated = DB::table('report_gift_requests') + ->where('id', (string) $gift->id) + ->whereNull('purchased_order_id') + ->update([ + 'purchased_order_id' => (string) $order->id, + 'purchased_by_user_id' => $this->normalizeActorId($payerUserId), + 'purchased_by_anon_id' => $this->normalizeActorId($payerAnonId), + 'status' => self::STATUS_PURCHASING, + 'updated_at' => now(), + ]); + if ($updated !== 1) { + return $this->error('GIFT_REQUEST_ALREADY_RESERVED', 'gift request already has a purchaser.', 409); + } + + return [ + 'ok' => true, + 'order' => $order, + 'order_no' => (string) $order->order_no, + 'idempotent' => (bool) ($orderResult['idempotent'] ?? false), + ]; + }); + } + + /** + * @return array + */ + public function createWechatMiniVirtualPaymentAction(object $order, string $loginCode): array + { + return DB::transaction(function () use ($order, $loginCode): array { + $freshOrder = DB::table('orders') + ->where('id', (string) ($order->id ?? '')) + ->where('org_id', (int) ($order->org_id ?? 0)) + ->where('order_no', (string) ($order->order_no ?? '')) + ->lockForUpdate() + ->first(); + if ($freshOrder === null) { + return $this->error('ORDER_NOT_FOUND', 'order not found.', 404); + } + + $gift = $this->findBoundGiftForOrder($freshOrder, true); + if ($gift === null) { + return $this->error('GIFT_REQUEST_NOT_PAYABLE', 'gift request is not payable.', 409); + } + $identityGuard = $this->validateGiftOrderIdentity($gift, $freshOrder); + if (($identityGuard['ok'] ?? false) !== true) { + return $identityGuard; + } + if ($this->effectiveStatus($gift) !== self::STATUS_PURCHASING + || ! in_array(Order::normalizePaymentState( + $freshOrder->payment_state ?? null, + $freshOrder->status ?? null + ), [Order::PAYMENT_STATE_CREATED, Order::PAYMENT_STATE_PENDING], true)) { + return $this->error('GIFT_REQUEST_NOT_PAYABLE', 'gift request is not payable.', 409); + } + + return $this->wechatMiniVirtual->createPaymentAction($freshOrder, $loginCode); + }, 3); + } + + public function findBoundGiftForOrder(object $order, bool $lockForUpdate = false): ?object + { + $orderId = trim((string) ($order->id ?? '')); + if ($orderId === '') { + return null; + } + + $query = DB::table('report_gift_requests')->where('purchased_order_id', $orderId); + if ($lockForUpdate) { + $query->lockForUpdate(); + } + + return $query->first(); + } + + /** + * @return array{ok:bool,error?:string,message?:string} + */ + public function restoreTerminalGiftForVerifiedPayment( + object $gift, + object $order, + bool $verifiedPaidEvent = false + ): array { + $identityGuard = $this->validateGiftOrderIdentity($gift, $order); + if (($identityGuard['ok'] ?? false) !== true) { + return $identityGuard; + } + + $orderIsPaid = in_array( + strtolower(trim((string) ($order->payment_state ?? $order->status ?? ''))), + ['paid', 'fulfilled'], + true + ); + if (! $verifiedPaidEvent && ! $orderIsPaid) { + return $this->error( + 'GIFT_PAYMENT_NOT_VERIFIED', + 'gift request can only be restored for a verified paid order.', + 409 + ); + } + + $status = $this->effectiveStatus($gift); + if (! in_array($status, [self::STATUS_CANCELED, self::STATUS_EXPIRED], true)) { + return ['ok' => true]; + } + + $competitors = DB::table('report_gift_requests') + ->where('org_id', (int) $gift->org_id) + ->where('target_attempt_id', (string) $gift->target_attempt_id) + ->where('id', '<>', (string) $gift->id) + ->lockForUpdate() + ->get(); + foreach ($competitors as $competitor) { + $competitorStatus = $this->effectiveStatus($competitor); + if ($competitorStatus === self::STATUS_FULFILLED) { + return $this->error( + 'GIFT_COMPETING_ORDER_FULFILLED', + 'another gift payment already fulfilled this report.', + 409 + ); + } + if (in_array($competitorStatus, [self::STATUS_PENDING, self::STATUS_PURCHASING], true)) { + if ($competitorStatus === self::STATUS_PURCHASING) { + $closed = $this->closeCompetingPurchasingOrder($competitor); + if (($closed['ok'] ?? false) !== true) { + return $closed; + } + } + DB::table('report_gift_requests') + ->where('id', (string) $competitor->id) + ->whereIn('status', [self::STATUS_PENDING, self::STATUS_PURCHASING]) + ->update([ + 'status' => self::STATUS_CANCELED, + 'canceled_at' => now(), + 'updated_at' => now(), + ]); + } + } + + $restored = DB::table('report_gift_requests') + ->where('id', (string) $gift->id) + ->whereIn('status', [self::STATUS_CANCELED, self::STATUS_EXPIRED]) + ->update([ + 'status' => self::STATUS_PURCHASING, + 'canceled_at' => null, + 'updated_at' => now(), + ]); + + return $restored === 1 + ? ['ok' => true] + : $this->error('GIFT_REQUEST_STATUS_CHANGED', 'gift request status changed.', 409); + } + + /** + * @return array{ok:bool,error?:string,message?:string} + */ + private function closeCompetingPurchasingOrder(object $gift): array + { + $orderId = trim((string) ($gift->purchased_order_id ?? '')); + if ($orderId === '') { + return $this->error( + 'GIFT_COMPETING_ORDER_NOT_FOUND', + 'competing gift order not found.', + 409 + ); + } + + $order = DB::table('orders') + ->where('id', $orderId) + ->where('org_id', (int) ($gift->org_id ?? 0)) + ->lockForUpdate() + ->first(); + if ($order === null) { + return $this->error( + 'GIFT_COMPETING_ORDER_NOT_FOUND', + 'competing gift order not found.', + 409 + ); + } + + $paymentState = Order::normalizePaymentState( + $order->payment_state ?? null, + $order->status ?? null + ); + if (in_array($paymentState, [Order::PAYMENT_STATE_PAID, Order::PAYMENT_STATE_REFUNDED], true)) { + return $this->error( + 'GIFT_COMPETING_ORDER_SETTLED', + 'competing gift order is already settled.', + 409 + ); + } + + $transition = $this->orders->transition( + (string) $order->order_no, + Order::STATUS_CANCELED, + (int) $order->org_id, + [ + 'payment_state' => Order::PAYMENT_STATE_CANCELED, + 'closed_at' => now(), + ] + ); + if (($transition['ok'] ?? false) !== true) { + return $this->error( + (string) ($transition['error'] ?? 'GIFT_COMPETING_ORDER_CLOSE_FAILED'), + (string) ($transition['message'] ?? 'competing gift order could not be closed.'), + 409 + ); + } + + $attempt = $this->orders->latestPaymentAttemptForOrder( + (string) $order->order_no, + (int) $order->org_id + ); + if ($attempt !== null && ! PaymentAttempt::isFinalState($attempt->state ?? null)) { + $this->orders->advancePaymentAttempt((string) $attempt->id, [ + 'state' => PaymentAttempt::STATE_CANCELED, + 'verified_at' => now(), + ]); + } + + return ['ok' => true]; + } + + /** + * @return array{ok:bool,error?:string,message?:string} + */ + public function validateBoundGiftOrder(object $gift, object $order): array + { + $identityGuard = $this->validateGiftOrderIdentity($gift, $order); + if (($identityGuard['ok'] ?? false) !== true) { + return $identityGuard; + } + if (! in_array($this->effectiveStatus($gift), [self::STATUS_PURCHASING, self::STATUS_FULFILLED], true)) { + return [ + 'ok' => false, + 'error' => 'GIFT_REQUEST_NOT_PAYABLE', + 'message' => 'gift request is not payable.', + ]; + } + + return ['ok' => true]; + } + + /** + * @param list $modules + * @return array + */ + public function grantVerifiedPaidGift( + object $gift, + object $order, + string $benefitCode, + string $scope, + ?string $expiresAt, + array $modules + ): array { + $guard = $this->validateBoundGiftOrder($gift, $order); + if (($guard['ok'] ?? false) !== true) { + return $guard; + } + + $existingGrant = DB::table('benefit_grants') + ->where('org_id', (int) $gift->org_id) + ->where('benefit_code', strtoupper(trim($benefitCode))) + ->where('scope', $scope) + ->where('attempt_id', (string) $gift->target_attempt_id) + ->lockForUpdate() + ->first(); + $orderNo = (string) $order->order_no; + $existingGrantIsUsable = $existingGrant !== null + && strtolower(trim((string) ($existingGrant->status ?? ''))) === 'active' + && ($existingGrant->expires_at === null || now()->lessThan($existingGrant->expires_at)); + $existingGrantBelongsToOrder = $existingGrantIsUsable + && trim((string) ($existingGrant->order_no ?? '')) === $orderNo; + + if ($existingGrant !== null && ! $existingGrantIsUsable) { + DB::table('benefit_grants') + ->where('id', (string) $existingGrant->id) + ->update([ + 'user_id' => $this->normalizeActorId($gift->recipient_user_id ?? null) + ?? $this->normalizeActorId($gift->recipient_anon_id ?? null) + ?? 'attempt:'.(string) $gift->target_attempt_id, + 'benefit_ref' => $this->normalizeActorId($gift->recipient_anon_id ?? null) + ?? $this->normalizeActorId($gift->recipient_user_id ?? null) + ?? 'attempt:'.(string) $gift->target_attempt_id, + 'order_no' => $orderNo, + 'source_order_id' => (string) $order->id, + 'status' => 'active', + 'expires_at' => $expiresAt, + 'revoked_at' => null, + 'updated_at' => now(), + ]); + $existingGrantBelongsToOrder = true; + } + + $grant = $this->entitlements->grantAttemptUnlock( + (int) $gift->org_id, + $this->normalizeActorId($gift->recipient_user_id ?? null), + $this->normalizeActorId($gift->recipient_anon_id ?? null), + $benefitCode, + (string) $gift->target_attempt_id, + $orderNo, + $scope, + $expiresAt, + $modules, + $existingGrant === null || $existingGrantBelongsToOrder + ? [ + 'unlock_source' => ReportAccess::UNLOCK_SOURCE_GIFT_PURCHASE, + 'granted_via' => ReportAccess::UNLOCK_SOURCE_GIFT_PURCHASE, + 'gift_request_id' => (string) $gift->id, + ] + : null + ); + if (($grant['ok'] ?? false) !== true) { + return $grant; + } + + DB::table('report_gift_requests') + ->where('id', (string) $gift->id) + ->whereIn('status', [self::STATUS_PURCHASING, self::STATUS_FULFILLED]) + ->update([ + 'status' => self::STATUS_FULFILLED, + 'fulfilled_at' => $gift->fulfilled_at ?? now(), + 'updated_at' => now(), + ]); + + return $grant; + } + + public function markRefundedForOrder(object $order): void + { + $orderId = trim((string) ($order->id ?? '')); + if ($orderId === '') { + return; + } + + DB::table('report_gift_requests') + ->where('purchased_order_id', $orderId) + ->whereIn('status', [self::STATUS_PURCHASING, self::STATUS_FULFILLED]) + ->update([ + 'status' => self::STATUS_REFUNDED, + 'updated_at' => now(), + ]); + } + + public function releaseReservationForOrder(object $order, string $paymentState): void + { + $orderId = trim((string) ($order->id ?? '')); + $orderNo = trim((string) ($order->order_no ?? '')); + $orgId = (int) ($order->org_id ?? 0); + if ($orderId === '' || $orderNo === '') { + return; + } + + $paymentAttempt = $this->orders->latestPaymentAttemptForOrder($orderNo, $orgId); + if ($paymentAttempt !== null && ! in_array( + PaymentAttempt::normalizedState($paymentAttempt->state ?? null), + [ + PaymentAttempt::STATE_FAILED, + PaymentAttempt::STATE_CANCELED, + PaymentAttempt::STATE_EXPIRED, + ], + true + )) { + return; + } + + $status = strtolower(trim($paymentState)) === 'expired' + ? self::STATUS_EXPIRED + : self::STATUS_CANCELED; + $updates = [ + 'status' => $status, + 'updated_at' => now(), + ]; + if ($status === self::STATUS_CANCELED) { + $updates['canceled_at'] = now(); + } + + DB::table('report_gift_requests') + ->where('purchased_order_id', $orderId) + ->where('status', self::STATUS_PURCHASING) + ->update($updates); + } + + public function releaseUnpayableReservationForOrder(object $order): void + { + $orderNo = trim((string) ($order->order_no ?? '')); + $orgId = (int) ($order->org_id ?? 0); + if ($orderNo === '' || $this->orders->latestPaymentAttemptForOrder($orderNo, $orgId) !== null) { + return; + } + + DB::transaction(function () use ($orderNo, $orgId): void { + $freshOrder = DB::table('orders') + ->where('order_no', $orderNo) + ->where('org_id', $orgId) + ->lockForUpdate() + ->first(); + if ($freshOrder === null || $this->orders->latestPaymentAttemptForOrder($orderNo, $orgId) !== null) { + return; + } + + $paymentState = strtolower(trim((string) ($freshOrder->payment_state ?? ''))); + if (in_array($paymentState, ['paid', 'refunded'], true)) { + return; + } + + $transition = $this->orders->transition($orderNo, 'canceled', $orgId, [ + 'payment_state' => 'canceled', + 'closed_at' => now(), + ]); + if (($transition['ok'] ?? false) !== true) { + return; + } + + DB::table('report_gift_requests') + ->where('purchased_order_id', (string) $freshOrder->id) + ->where('status', self::STATUS_PURCHASING) + ->update([ + 'purchased_order_id' => null, + 'purchased_by_user_id' => null, + 'purchased_by_anon_id' => null, + 'status' => self::STATUS_PENDING, + 'updated_at' => now(), + ]); + }); + } + + /** + * @param array $payload + * @return array + */ + public function presentOwnedOrderPayload(int $orgId, string $orderNo, array $payload): array + { + $giftOrderExists = DB::table('report_gift_requests as gifts') + ->join('orders', 'orders.id', '=', 'gifts.purchased_order_id') + ->where('orders.org_id', $orgId) + ->where('orders.order_no', trim($orderNo)) + ->exists(); + if (! $giftOrderExists) { + return $payload; + } + + $payload['attempt_id'] = null; + $payload['result_url'] = null; + $payload['delivery'] = [ + 'can_view_report' => false, + 'report_url' => null, + 'can_download_pdf' => false, + 'report_pdf_url' => null, + 'can_resend' => false, + 'contact_email_present' => false, + 'last_delivery_email_sent_at' => null, + 'can_request_claim_email' => false, + ]; + $payload['exact_result_entry'] = null; + $payload['mbti_form_v1'] = null; + $payload['big5_form_v1'] = null; + unset($payload['order'], $payload[ReportAccess::ACCESS_HUB_KEY]); + + return $payload; + } + + /** + * @return array{ok:bool,error?:string,message?:string} + */ + private function validateGiftOrderIdentity(object $gift, object $order): array + { + if ((int) ($gift->org_id ?? -1) !== (int) ($order->org_id ?? -2) + || trim((string) ($gift->target_attempt_id ?? '')) !== trim((string) ($order->target_attempt_id ?? '')) + || strtoupper(trim((string) ($gift->sku ?? ''))) !== strtoupper(trim((string) ($order->sku ?? ''))) + || ! $this->actorMatchesPurchaser($gift, $order->user_id ?? null, $order->anon_id ?? null)) { + return [ + 'ok' => false, + 'error' => 'GIFT_ORDER_MISMATCH', + 'message' => 'gift request does not match the paid order.', + ]; + } + + return ['ok' => true]; + } + + private function findByToken(string $token, int $orgId): ?object + { + if (preg_match('/^[A-Za-z0-9_-]{43}$/', trim($token)) !== 1) { + return null; + } + + return DB::table('report_gift_requests') + ->where('public_token_hash', $this->tokenHash($token)) + ->where('org_id', $orgId) + ->first(); + } + + /** + * @return array + */ + private function publicSummary(object $gift): array + { + $skuRow = $this->skus->getActiveSku((string) $gift->sku, null, (int) $gift->org_id); + $priceCents = (int) ($skuRow->price_cents ?? 0); + $currency = strtoupper(trim((string) ($skuRow->currency ?? 'CNY'))); + $status = $this->effectiveStatus($gift); + $skuValid = $this->skuContractIsValid($skuRow); + + return [ + 'status' => $status, + 'scale_code' => (string) $gift->scale_code, + 'sku' => (string) $gift->sku, + 'expires_at' => (string) $gift->expires_at, + 'price_cents' => $priceCents, + 'currency' => $currency, + 'display_price' => $currency === 'CNY' ? '¥'.number_format($priceCents / 100, 2, '.', '') : null, + 'can_purchase' => $status === self::STATUS_PENDING + && $this->giftAvailable() + && $this->paymentProviders->isEnabled(self::PROVIDER) + && $skuValid, + ]; + } + + /** + * @return array{ok:bool,error_code?:string,message?:string,status?:int} + */ + private function validateSku(int $orgId, string $sku): array + { + $skuRow = $this->skus->getActiveSku($sku, null, $orgId); + if (! $skuRow) { + return $this->error('SKU_NOT_FOUND', 'gift SKU is unavailable.', 404); + } + if (! $this->skuContractIsValid($skuRow)) { + return $this->error('GIFT_SKU_INVALID', 'gift SKU contract is invalid.', 409); + } + + return ['ok' => true]; + } + + private function skuContractIsValid(?object $skuRow): bool + { + return $skuRow !== null + && (int) ($skuRow->price_cents ?? 0) === (int) config('report_unlock.price_cents', 499) + && strtoupper(trim((string) ($skuRow->currency ?? ''))) === 'CNY' + && strtolower(trim((string) ($skuRow->kind ?? ''))) === 'report_unlock'; + } + + private function actorOwnsAttempt(object $attempt, ?string $userId, ?string $anonId): bool + { + $actorUserId = $this->normalizeActorId($userId); + $actorAnonId = $this->normalizeActorId($anonId); + $attemptUserId = $this->normalizeActorId($attempt->user_id ?? null); + $attemptAnonId = $this->normalizeActorId($attempt->anon_id ?? null); + + return ($actorUserId !== null && $actorUserId === $attemptUserId) + || ($actorAnonId !== null && $actorAnonId === $attemptAnonId); + } + + private function actorMatchesRecipient(object $gift, ?string $userId, ?string $anonId): bool + { + return ($this->normalizeActorId($userId) !== null + && $this->normalizeActorId($userId) === $this->normalizeActorId($gift->recipient_user_id ?? null)) + || ($this->normalizeActorId($anonId) !== null + && $this->normalizeActorId($anonId) === $this->normalizeActorId($gift->recipient_anon_id ?? null)); + } + + private function actorMatchesPurchaser(object $gift, mixed $userId, mixed $anonId): bool + { + $expectedUserId = $this->normalizeActorId($gift->purchased_by_user_id ?? null); + $expectedAnonId = $this->normalizeActorId($gift->purchased_by_anon_id ?? null); + $actualUserId = $this->normalizeActorId($userId); + $actualAnonId = $this->normalizeActorId($anonId); + + return ($expectedUserId !== null && $expectedUserId === $actualUserId) + || ($expectedAnonId !== null && $expectedAnonId === $actualAnonId); + } + + private function effectiveStatus(object $gift): string + { + $status = strtolower(trim((string) ($gift->status ?? ''))); + if ($status === self::STATUS_PENDING && now()->greaterThanOrEqualTo($gift->expires_at)) { + return self::STATUS_EXPIRED; + } + + return in_array($status, [ + self::STATUS_PENDING, + self::STATUS_PURCHASING, + self::STATUS_FULFILLED, + self::STATUS_CANCELED, + self::STATUS_EXPIRED, + self::STATUS_REFUNDED, + ], true) ? $status : self::STATUS_CANCELED; + } + + private function skuForScale(string $scaleCode): string + { + return strtoupper(trim((string) config('report_unlock.sku_by_scale.'.$scaleCode, ''))); + } + + private function giftAvailable(): bool + { + return (bool) config('report_unlock.providers.gift_purchase.available', false); + } + + private function ttlHours(): int + { + return min(168, max(1, (int) config('report_unlock.gift_request_ttl_hours', 72))); + } + + private function generateToken(): string + { + return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); + } + + private function tokenHash(string $token): string + { + return hash('sha256', trim($token)); + } + + private function giftIdempotencyKey( + string $giftRequestId, + string $key, + ?string $payerUserId, + ?string $payerAnonId, + string $reservationGeneration + ): string { + $payerIdentity = $payerUserId !== null + ? 'user:'.$payerUserId + : 'anon:'.(string) $payerAnonId; + + return 'gift:'.$giftRequestId.':'.hash( + 'sha256', + $payerIdentity."\0".$key."\0".$reservationGeneration + ); + } + + private function giftOrderReservationGeneration( + object $gift, + ?string $payerUserId, + ?string $payerAnonId + ): string { + return (string) DB::table('orders') + ->where('org_id', (int) ($gift->org_id ?? 0)) + ->where('target_attempt_id', (string) ($gift->target_attempt_id ?? '')) + ->where('sku', (string) ($gift->sku ?? '')) + ->where('provider', self::PROVIDER) + ->where('user_id', $this->normalizeActorId($payerUserId)) + ->where('anon_id', $this->normalizeActorId($payerAnonId)) + ->count(); + } + + private function normalizeActorId(mixed $value): ?string + { + $normalized = trim((string) $value); + + return $normalized !== '' ? $normalized : null; + } + + /** + * @return array{ok:false,error_code:string,message:string,status:int} + */ + private function error(string $code, string $message, int $status): array + { + return [ + 'ok' => false, + 'error_code' => $code, + 'message' => $message, + 'status' => $status, + ]; + } +} diff --git a/backend/app/Services/Commerce/Webhook/WebhookEntitlementService.php b/backend/app/Services/Commerce/Webhook/WebhookEntitlementService.php index 0e8ad6d2d..90052356e 100644 --- a/backend/app/Services/Commerce/Webhook/WebhookEntitlementService.php +++ b/backend/app/Services/Commerce/Webhook/WebhookEntitlementService.php @@ -4,6 +4,7 @@ use App\Internal\Commerce\PaymentWebhookHandlerCore; use App\Services\Commerce\Repair\OrderRepairService; +use App\Services\Commerce\ReportGiftService; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -344,6 +345,7 @@ public function handle(array $ctx): array return $refund; } + app(ReportGiftService::class)->markRefundedForOrder($order); if ($boundPaymentAttempt !== null) { $this->core->orderManager()->advancePaymentAttempt((string) ($boundPaymentAttempt->id ?? ''), [ 'state' => \App\Models\PaymentAttempt::STATE_VERIFIED, @@ -380,7 +382,6 @@ public function handle(array $ctx): array return $transition; } - if ($boundPaymentAttempt !== null) { $this->core->orderManager()->advancePaymentAttempt((string) ($boundPaymentAttempt->id ?? ''), [ 'state' => match ($nonSuccessPaymentState) { @@ -393,6 +394,10 @@ public function handle(array $ctx): array 'verified_at' => $eventAt, ]); } + app(ReportGiftService::class)->releaseReservationForOrder( + $order, + $nonSuccessPaymentState + ); $this->core->markEventProcessed($provider, $providerEventId); return [ @@ -436,6 +441,23 @@ public function handle(array $ctx): array return $this->core->semanticReject($guardCode, $guardMessage); } + $giftService = app(ReportGiftService::class); + $giftRequest = $giftService->findBoundGiftForOrder($order, true); + if ($giftRequest !== null) { + $giftRestore = $giftService->restoreTerminalGiftForVerifiedPayment( + $giftRequest, + $order, + true + ); + if (! ($giftRestore['ok'] ?? false)) { + $code = (string) ($giftRestore['error'] ?? 'GIFT_REQUEST_NOT_PAYABLE'); + $message = (string) ($giftRestore['message'] ?? 'gift request is not payable.'); + $this->core->markEventError($provider, $providerEventId, 'rejected', $code, $message); + + return $this->core->semanticReject($code, $message); + } + } + if (! $orderAlreadySettled) { $orderTransition = $this->core->orderManager()->transitionToPaidAtomic( $orderNo, @@ -529,8 +551,12 @@ public function handle(array $ctx): array ], $anonId); $eventUserId = $order->user_id ? (string) $order->user_id : $userId; $repairService = app(OrderRepairService::class); + $giftGrantIntentionallyRevoked = $giftRequest !== null + && $orderAlreadySettled + && strtolower(trim((string) ($order->grant_state ?? ''))) === \App\Models\Order::GRANT_STATE_REVOKED; $grantMissingOnSettledOrder = $kind === 'report_unlock' && $orderAlreadySettled + && ! $giftGrantIntentionallyRevoked && ! $repairService->hasActiveGrantForOrder($order); $retryingPostCommitOnly = $inserted === 0 @@ -572,10 +598,14 @@ public function handle(array $ctx): array return $this->core->semanticReject('ATTEMPT_REQUIRED', 'target_attempt_id is required for report_unlock.'); } - $ownerGuard = $this->core->validateAttemptOwnershipForOrder($order, $attemptMeta); - if (! ($ownerGuard['ok'] ?? false)) { - $code = (string) ($ownerGuard['error'] ?? 'ATTEMPT_OWNER_MISMATCH'); - $message = (string) ($ownerGuard['message'] ?? 'order owner mismatch.'); + $giftService = app(ReportGiftService::class); + $giftRequest = $giftService->findBoundGiftForOrder($order, true); + $ownershipGuard = $giftRequest !== null + ? $giftService->validateBoundGiftOrder($giftRequest, $order) + : $this->core->validateAttemptOwnershipForOrder($order, $attemptMeta); + if (! ($ownershipGuard['ok'] ?? false)) { + $code = (string) ($ownershipGuard['error'] ?? 'ATTEMPT_OWNER_MISMATCH'); + $message = (string) ($ownershipGuard['message'] ?? 'order owner mismatch.'); $this->core->markEventError($provider, $providerEventId, 'rejected', $code, $message); return $this->core->semanticReject($code, $message); @@ -590,7 +620,7 @@ public function handle(array $ctx): array return $this->core->semanticReject($code, $message); } - if (! $retryingPostCommitOnly) { + if (! $retryingPostCommitOnly && ! $giftGrantIntentionallyRevoked) { $scopeOverride = trim((string) ($skuRow->scope ?? '')); if ($scopeOverride === '') { $scopeOverride = 'attempt'; @@ -606,17 +636,26 @@ public function handle(array $ctx): array } } - $grant = $this->core->entitlementManager()->grantAttemptUnlock( - (int) $order->org_id, - $order->user_id ? (string) $order->user_id : $userId, - $order->anon_id ? (string) $order->anon_id : $anonId, - $benefitCode, - $attemptId, - $orderNo, - $scopeOverride, - $expiresAt, - $modulesIncluded - ); + $grant = $giftRequest !== null + ? $giftService->grantVerifiedPaidGift( + $giftRequest, + $order, + $benefitCode, + $scopeOverride, + $expiresAt, + $modulesIncluded + ) + : $this->core->entitlementManager()->grantAttemptUnlock( + (int) $order->org_id, + $order->user_id ? (string) $order->user_id : $userId, + $order->anon_id ? (string) $order->anon_id : $anonId, + $benefitCode, + $attemptId, + $orderNo, + $scopeOverride, + $expiresAt, + $modulesIncluded + ); if (! ($grant['ok'] ?? false)) { $this->core->orderManager()->syncGrantState($orderNo, $orgId, \App\Models\Order::GRANT_STATE_GRANT_FAILED); diff --git a/backend/config/report_unlock.php b/backend/config/report_unlock.php index 761185293..ea5f02077 100644 --- a/backend/config/report_unlock.php +++ b/backend/config/report_unlock.php @@ -16,6 +16,7 @@ static fn (mixed $value): string => trim((string) $value), explode(',', (string) env('REPORT_UNLOCK_SUPPORTED_LOCALES', 'zh-CN')) ))), + 'gift_request_ttl_hours' => (int) env('REPORT_UNLOCK_GIFT_REQUEST_TTL_HOURS', 72), 'sku_by_scale' => [ 'MBTI' => env('REPORT_UNLOCK_MBTI_SKU', 'MBTI_REPORT_FULL'), ], diff --git a/backend/docs/contracts/openapi.snapshot.json b/backend/docs/contracts/openapi.snapshot.json index 5ddab812a..9b22f36c3 100644 --- a/backend/docs/contracts/openapi.snapshot.json +++ b/backend/docs/contracts/openapi.snapshot.json @@ -557,6 +557,83 @@ "x-laravel-name": "api.v0_3.attempts.eq.journey.submit" } }, + "/api/v0.3/attempts/{id}/gift-requests": { + "post": { + "operationId": "api.v0_3.attempts.gift_requests.store", + "tags": [ + "api:v0.3" + ], + "responses": { + "200": { + "description": "OK" + } + }, + "x-laravel-action": "App\\Http\\Controllers\\API\\V0_3\\ReportGiftController@store", + "x-laravel-middleware": [ + "api", + "Illuminate\\Routing\\Middleware\\ThrottleRequests:api_public", + "App\\Http\\Middleware\\NormalizeApiErrorContract", + "App\\Http\\Middleware\\ResolveAnonId", + "App\\Http\\Middleware\\ResolveOrgContext", + "App\\Http\\Middleware\\ForcePublicAttemptRealm", + "App\\Http\\Middleware\\FmTokenAuth", + "App\\Http\\Middleware\\EnsureUuidRouteParams:id" + ], + "x-laravel-name": "api.v0_3.attempts.gift_requests.store" + } + }, + "/api/v0.3/attempts/{id}/gift-requests/{gift_request_id}": { + "get": { + "operationId": "api.v0_3.attempts.gift_requests.show", + "tags": [ + "api:v0.3" + ], + "responses": { + "200": { + "description": "OK" + } + }, + "x-laravel-action": "App\\Http\\Controllers\\API\\V0_3\\ReportGiftController@showOwner", + "x-laravel-middleware": [ + "api", + "Illuminate\\Routing\\Middleware\\ThrottleRequests:api_public", + "App\\Http\\Middleware\\NormalizeApiErrorContract", + "App\\Http\\Middleware\\ResolveAnonId", + "App\\Http\\Middleware\\ResolveOrgContext", + "App\\Http\\Middleware\\ForcePublicAttemptRealm", + "App\\Http\\Middleware\\FmTokenAuth", + "App\\Http\\Middleware\\EnsureUuidRouteParams:id", + "App\\Http\\Middleware\\EnsureUuidRouteParams:gift_request_id" + ], + "x-laravel-name": "api.v0_3.attempts.gift_requests.show" + } + }, + "/api/v0.3/attempts/{id}/gift-requests/{gift_request_id}/cancel": { + "post": { + "operationId": "api.v0_3.attempts.gift_requests.cancel", + "tags": [ + "api:v0.3" + ], + "responses": { + "200": { + "description": "OK" + } + }, + "x-laravel-action": "App\\Http\\Controllers\\API\\V0_3\\ReportGiftController@cancel", + "x-laravel-middleware": [ + "api", + "Illuminate\\Routing\\Middleware\\ThrottleRequests:api_public", + "App\\Http\\Middleware\\NormalizeApiErrorContract", + "App\\Http\\Middleware\\ResolveAnonId", + "App\\Http\\Middleware\\ResolveOrgContext", + "App\\Http\\Middleware\\ForcePublicAttemptRealm", + "App\\Http\\Middleware\\FmTokenAuth", + "App\\Http\\Middleware\\EnsureUuidRouteParams:id", + "App\\Http\\Middleware\\EnsureUuidRouteParams:gift_request_id" + ], + "x-laravel-name": "api.v0_3.attempts.gift_requests.cancel" + } + }, "/api/v0.3/attempts/{id}/invite-unlocks": { "get": { "operationId": "api.v0_3.attempts.invite_unlocks.show", @@ -1361,7 +1438,7 @@ "description": "OK" } }, - "x-laravel-action": "App\\Http\\Controllers\\API\\V0_3\\CommerceController@getOrder", + "x-laravel-action": "App\\Http\\Controllers\\API\\V0_3\\ReportGiftController@getOrder", "x-laravel-middleware": [ "api", "Illuminate\\Routing\\Middleware\\ThrottleRequests:api_public", @@ -1970,6 +2047,51 @@ ] } }, + "/api/v0.3/report-gifts/{token}": { + "get": { + "operationId": "api.v0_3.report_gifts.show", + "tags": [ + "api:v0.3" + ], + "responses": { + "200": { + "description": "OK" + } + }, + "x-laravel-action": "App\\Http\\Controllers\\API\\V0_3\\ReportGiftController@showPublic", + "x-laravel-middleware": [ + "api", + "Illuminate\\Routing\\Middleware\\ThrottleRequests:api_public", + "App\\Http\\Middleware\\NormalizeApiErrorContract", + "App\\Http\\Middleware\\ResolveAnonId", + "App\\Http\\Middleware\\ResolveOrgContext" + ], + "x-laravel-name": "api.v0_3.report_gifts.show" + } + }, + "/api/v0.3/report-gifts/{token}/orders/wechat_mini_virtual": { + "post": { + "operationId": "api.v0_3.report_gifts.orders.wechat_mini_virtual.store", + "tags": [ + "api:v0.3" + ], + "responses": { + "200": { + "description": "OK" + } + }, + "x-laravel-action": "App\\Http\\Controllers\\API\\V0_3\\ReportGiftController@purchaseWechatMiniVirtual", + "x-laravel-middleware": [ + "api", + "Illuminate\\Routing\\Middleware\\ThrottleRequests:api_public", + "App\\Http\\Middleware\\NormalizeApiErrorContract", + "App\\Http\\Middleware\\ResolveAnonId", + "App\\Http\\Middleware\\ResolveOrgContext", + "App\\Http\\Middleware\\FmTokenAuth" + ], + "x-laravel-name": "api.v0_3.report_gifts.orders.wechat_mini_virtual.store" + } + }, "/api/v0.3/results/lookup-by-email": { "post": { "operationId": "api.v0_3.results.lookup_by_email", diff --git a/backend/routes/api.php b/backend/routes/api.php index 861506822..714c9e142 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -23,6 +23,7 @@ use App\Http\Controllers\API\V0_3\OrgsController; use App\Http\Controllers\API\V0_3\PublicGatewaySurfaceController; use App\Http\Controllers\API\V0_3\PublicTestMetricsSummaryController; +use App\Http\Controllers\API\V0_3\ReportGiftController; use App\Http\Controllers\API\V0_3\ResultEmailLookupController; use App\Http\Controllers\API\V0_3\ScalesController; use App\Http\Controllers\API\V0_3\ScalesLookupController; @@ -345,6 +346,18 @@ ->middleware([\App\Http\Middleware\FmTokenAuth::class, 'uuid:id']) ->defaults('public_realm', true) ->name('api.v0_3.attempts.invite_unlocks.show'); + Route::post('/attempts/{id}/gift-requests', [ReportGiftController::class, 'store']) + ->middleware([\App\Http\Middleware\FmTokenAuth::class, 'uuid:id']) + ->defaults('public_realm', true) + ->name('api.v0_3.attempts.gift_requests.store'); + Route::get('/attempts/{id}/gift-requests/{gift_request_id}', [ReportGiftController::class, 'showOwner']) + ->middleware([\App\Http\Middleware\FmTokenAuth::class, 'uuid:id', 'uuid:gift_request_id']) + ->defaults('public_realm', true) + ->name('api.v0_3.attempts.gift_requests.show'); + Route::post('/attempts/{id}/gift-requests/{gift_request_id}/cancel', [ReportGiftController::class, 'cancel']) + ->middleware([\App\Http\Middleware\FmTokenAuth::class, 'uuid:id', 'uuid:gift_request_id']) + ->defaults('public_realm', true) + ->name('api.v0_3.attempts.gift_requests.cancel'); }); // Share contract routes stay fixed; summary/click semantics are implemented in ShareController/services. Route::match(['GET', 'POST'], '/attempts/{id}/share', [ShareV03Controller::class, 'getShare']) @@ -392,6 +405,15 @@ Route::post('/orders/{provider}', 'App\\Http\\Controllers\\API\\V0_3\\CommerceController@createOrder') ->middleware(\App\Http\Middleware\FmTokenAuth::class) ->whereIn('provider', $payProviders); + Route::get('/report-gifts/{token}', [ReportGiftController::class, 'showPublic']) + ->where('token', '[A-Za-z0-9_-]{43}') + ->name('api.v0_3.report_gifts.show'); + Route::post( + '/report-gifts/{token}/orders/wechat_mini_virtual', + [ReportGiftController::class, 'purchaseWechatMiniVirtual'] + )->middleware(\App\Http\Middleware\FmTokenAuth::class) + ->where('token', '[A-Za-z0-9_-]{43}') + ->name('api.v0_3.report_gifts.orders.wechat_mini_virtual.store'); Route::post( '/orders/{order_no}/wechat-mini-virtual/reconcile', 'App\\Http\\Controllers\\API\\V0_3\\CommerceController@reconcileWechatMiniVirtual' @@ -399,7 +421,7 @@ ->name('api.v0_3.orders.wechat_mini_virtual.reconcile'); Route::get('/orders/{order_no}/pay/alipay', 'App\\Http\\Controllers\\API\\V0_3\\CommerceController@launchAlipay') ->middleware(\App\Http\Middleware\FmTokenOptional::class); - Route::get('/orders/{order_no}', 'App\\Http\\Controllers\\API\\V0_3\\CommerceController@getOrder') + Route::get('/orders/{order_no}', [ReportGiftController::class, 'getOrder']) ->middleware(\App\Http\Middleware\FmTokenOptional::class); Route::get('/shares/{id}', [ShareV03Controller::class, 'getShareView']); Route::post('/shares/{shareId}/click', [ShareV03Controller::class, 'click']) diff --git a/backend/tests/Feature/Commerce/PaymentRepairEngineTest.php b/backend/tests/Feature/Commerce/PaymentRepairEngineTest.php index f7f747d53..910372681 100644 --- a/backend/tests/Feature/Commerce/PaymentRepairEngineTest.php +++ b/backend/tests/Feature/Commerce/PaymentRepairEngineTest.php @@ -140,13 +140,13 @@ public function test_post_commit_repair_command_reprocesses_failed_event(): void $seedCalls = 0; $snapshotStore->shouldReceive('seedPendingSnapshot') ->twice() - ->andReturnUsing(function (int $orgId, string $attemptIdArg, ?string $orderNoArg, array $meta) use (&$seedCalls, $realSnapshotStore): void { + ->andReturnUsing(function (int $orgId, string $attemptIdArg, ?string $orderNoArg, array $meta) use (&$seedCalls, $realSnapshotStore): bool { $seedCalls++; if ($seedCalls === 1) { throw new \RuntimeException('simulated_post_commit_failure'); } - $realSnapshotStore->seedPendingSnapshot($orgId, $attemptIdArg, $orderNoArg, $meta); + return $realSnapshotStore->seedPendingSnapshot($orgId, $attemptIdArg, $orderNoArg, $meta); }); $this->app->instance(ReportSnapshotStore::class, $snapshotStore); @@ -199,13 +199,13 @@ public function test_commerce_repair_post_commit_failed_default_scope_repairs_te $seedCalls = 0; $snapshotStore->shouldReceive('seedPendingSnapshot') ->twice() - ->andReturnUsing(function (int $orgId, string $attemptIdArg, ?string $orderNoArg, array $meta) use (&$seedCalls, $realSnapshotStore): void { + ->andReturnUsing(function (int $orgId, string $attemptIdArg, ?string $orderNoArg, array $meta) use (&$seedCalls, $realSnapshotStore): bool { $seedCalls++; if ($seedCalls === 1) { throw new \RuntimeException('simulated_tenant_post_commit_failure'); } - $realSnapshotStore->seedPendingSnapshot($orgId, $attemptIdArg, $orderNoArg, $meta); + return $realSnapshotStore->seedPendingSnapshot($orgId, $attemptIdArg, $orderNoArg, $meta); }); $this->app->instance(ReportSnapshotStore::class, $snapshotStore); diff --git a/backend/tests/Feature/Commerce/ReportGiftPurchaseContractTest.php b/backend/tests/Feature/Commerce/ReportGiftPurchaseContractTest.php new file mode 100644 index 000000000..d890569fd --- /dev/null +++ b/backend/tests/Feature/Commerce/ReportGiftPurchaseContractTest.php @@ -0,0 +1,1282 @@ +configureProvider(); + (new ScaleRegistrySeeder)->run(); + (new Pr19CommerceSeeder)->run(); + $this->seedSku(); + } + + public function test_only_owner_can_create_and_public_token_never_leaks_attempt_or_private_result(): void + { + $attemptId = $this->createAttempt('anon_gift_recipient'); + $ownerHeaders = $this->headersFor('anon_gift_recipient'); + $foreignHeaders = $this->headersFor('anon_gift_foreign'); + + $this->withHeaders($foreignHeaders) + ->postJson('/api/v0.3/attempts/'.$attemptId.'/gift-requests') + ->assertForbidden() + ->assertJsonPath('error_code', 'ATTEMPT_OWNER_MISMATCH'); + + $created = $this->withHeaders($ownerHeaders) + ->postJson('/api/v0.3/attempts/'.$attemptId.'/gift-requests') + ->assertCreated() + ->assertJsonPath('ok', true) + ->assertJsonPath('gift_request.status', 'pending'); + $token = (string) $created->json('gift_request.public_token'); + $giftId = (string) $created->json('gift_request.id'); + $this->assertMatchesRegularExpression('/^[A-Za-z0-9_-]{43}$/', $token); + + $stored = DB::table('report_gift_requests')->where('id', $giftId)->first(); + $this->assertNotNull($stored); + $this->assertSame(hash('sha256', $token), (string) $stored->public_token_hash); + $this->assertSame(64, strlen((string) $stored->public_token_hash)); + $this->assertStringNotContainsString($token, json_encode($stored, JSON_THROW_ON_ERROR)); + + $this->withHeaders($foreignHeaders) + ->getJson('/api/v0.3/attempts/'.$attemptId.'/gift-requests/'.$giftId) + ->assertForbidden() + ->assertJsonPath('error_code', 'GIFT_REQUEST_FORBIDDEN'); + $this->withHeaders($foreignHeaders) + ->postJson('/api/v0.3/attempts/'.$attemptId.'/gift-requests/'.$giftId.'/cancel') + ->assertForbidden() + ->assertJsonPath('error_code', 'GIFT_REQUEST_FORBIDDEN'); + $this->withHeaders($ownerHeaders) + ->postJson('/api/v0.3/attempts/'.$attemptId.'/gift-requests') + ->assertStatus(409) + ->assertJsonPath('error_code', 'GIFT_REQUEST_ACTIVE'); + + $public = $this->getJson('/api/v0.3/report-gifts/'.$token) + ->assertOk() + ->assertJsonPath('gift.status', 'pending') + ->assertJsonPath('gift.scale_code', 'MBTI') + ->assertJsonPath('gift.price_cents', 499) + ->assertJsonPath('gift.currency', 'CNY') + ->assertJsonPath('gift.display_price', '¥4.99') + ->assertJsonMissingPath('gift.target_attempt_id') + ->assertJsonMissingPath('gift.recipient_user_id') + ->assertJsonMissingPath('gift.recipient_anon_id') + ->assertJsonMissingPath('gift.result'); + $this->assertStringNotContainsString($attemptId, $public->getContent()); + + $this->assertSame(0, DB::table('benefit_grants')->where('attempt_id', $attemptId)->count()); + + $this->withHeaders($ownerHeaders) + ->postJson('/api/v0.3/attempts/'.$attemptId.'/gift-requests/'.$giftId.'/cancel') + ->assertOk() + ->assertJsonPath('gift_request.status', 'canceled'); + $this->getJson('/api/v0.3/report-gifts/'.$token) + ->assertOk() + ->assertJsonPath('gift.status', 'canceled') + ->assertJsonPath('gift.can_purchase', false); + $this->assertSame(0, DB::table('benefit_grants')->where('attempt_id', $attemptId)->count()); + } + + public function test_expired_and_iq_gift_requests_fail_closed_without_orders_or_grants(): void + { + $attemptId = $this->createAttempt('anon_gift_expired'); + $created = $this->withHeaders($this->headersFor('anon_gift_expired')) + ->postJson('/api/v0.3/attempts/'.$attemptId.'/gift-requests') + ->assertCreated(); + $token = (string) $created->json('gift_request.public_token'); + DB::table('report_gift_requests') + ->where('id', (string) $created->json('gift_request.id')) + ->update(['expires_at' => now()->subSecond()]); + + $this->withHeaders($this->headersFor('anon_gift_payer')) + ->postJson('/api/v0.3/report-gifts/'.$token.'/orders/wechat_mini_virtual', [ + 'idempotency_key' => 'gift-expired-order', + 'wx_login_code' => 'wx-login-expired', + ]) + ->assertStatus(410) + ->assertJsonPath('error_code', 'GIFT_REQUEST_EXPIRED'); + $this->assertSame(0, DB::table('orders')->count()); + $this->assertSame(0, DB::table('benefit_grants')->count()); + + $iqAttemptId = $this->createAttempt('anon_gift_iq', 'IQ_RAVEN'); + $this->withHeaders($this->headersFor('anon_gift_iq')) + ->postJson('/api/v0.3/attempts/'.$iqAttemptId.'/gift-requests') + ->assertForbidden() + ->assertJsonPath('error_code', 'IQ_GIFT_DISABLED'); + } + + public function test_verified_friend_payment_grants_recipient_once_and_refund_revokes_it(): void + { + $recipientAnonId = 'anon_gift_recipient_paid'; + $payerAnonId = 'anon_gift_payer_paid'; + $otherPayerAnonId = 'anon_gift_other_payer'; + $attemptId = $this->createAttempt($recipientAnonId); + $created = $this->withHeaders($this->headersFor($recipientAnonId)) + ->postJson('/api/v0.3/attempts/'.$attemptId.'/gift-requests') + ->assertCreated(); + $token = (string) $created->json('gift_request.public_token'); + $giftId = (string) $created->json('gift_request.id'); + + Http::fake([ + 'https://api.weixin.qq.com/sns/jscode2session*' => Http::response([ + 'openid' => 'openid-gift-payer', + 'session_key' => 'session-key-gift-payer', + ]), + ]); + $purchasePayload = [ + 'idempotency_key' => 'gift-order-stable-key', + 'wx_login_code' => 'wx-login-gift-payer', + ]; + $purchased = $this->withHeaders($this->headersFor($payerAnonId)) + ->postJson('/api/v0.3/report-gifts/'.$token.'/orders/wechat_mini_virtual', $purchasePayload) + ->assertOk() + ->assertJsonPath('pay.type', 'wechat_mini_virtual'); + $orderNo = (string) $purchased->json('order_no'); + $signData = json_decode((string) $purchased->json('pay.params.signData'), true, flags: JSON_THROW_ON_ERROR); + + $duplicate = $this->withHeaders($this->headersFor($payerAnonId)) + ->postJson('/api/v0.3/report-gifts/'.$token.'/orders/wechat_mini_virtual', $purchasePayload) + ->assertOk(); + $this->assertSame($orderNo, (string) $duplicate->json('order_no')); + $this->assertSame(1, DB::table('orders')->where('order_no', $orderNo)->count()); + + $this->withHeaders($this->headersFor($otherPayerAnonId)) + ->postJson('/api/v0.3/report-gifts/'.$token.'/orders/wechat_mini_virtual', [ + 'idempotency_key' => 'gift-order-race-loser', + 'wx_login_code' => 'wx-login-race-loser', + ]) + ->assertStatus(409) + ->assertJsonPath('error_code', 'GIFT_REQUEST_ALREADY_RESERVED'); + DB::table('report_gift_requests')->where('id', $giftId)->update(['expires_at' => now()->subSecond()]); + $this->withHeaders($this->headersFor($recipientAnonId)) + ->postJson('/api/v0.3/attempts/'.$attemptId.'/gift-requests') + ->assertStatus(409) + ->assertJsonPath('error_code', 'GIFT_REQUEST_ACTIVE'); + + $this->assertSame(0, DB::table('benefit_grants')->where('attempt_id', $attemptId)->count()); + $callbackPayload = $this->paidCallbackPayload($orderNo, (string) $signData['outTradeNo']); + $handled = app(WechatMiniVirtualPaymentService::class) + ->handleCallback($this->signedCallbackRequest($callbackPayload)); + $this->assertTrue( + (bool) ($handled['ok'] ?? false), + json_encode($handled, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + $this->assertSame('fulfilled', (string) DB::table('report_gift_requests')->where('id', $giftId)->value('status')); + $this->assertSame(1, DB::table('benefit_grants') + ->where('attempt_id', $attemptId) + ->where('order_no', $orderNo) + ->where('status', 'active') + ->count()); + $grant = DB::table('benefit_grants')->where('order_no', $orderNo)->first(); + $this->assertSame($recipientAnonId, (string) ($grant->benefit_ref ?? '')); + $this->assertStringContainsString('"unlock_source":"gift_purchase"', (string) ($grant->meta_json ?? '')); + + app(WechatMiniVirtualPaymentService::class) + ->handleCallback($this->signedCallbackRequest($callbackPayload)); + $this->assertSame(1, DB::table('benefit_grants')->where('attempt_id', $attemptId)->count()); + + $this->withHeaders($this->headersFor($payerAnonId)) + ->getJson('/api/v0.3/attempts/'.$attemptId.'/report-access') + ->assertNotFound(); + $this->withHeaders($this->headersFor($recipientAnonId)) + ->getJson('/api/v0.3/attempts/'.$attemptId.'/report-access') + ->assertOk() + ->assertJsonPath('access_level', 'full') + ->assertJsonPath('full_report_entitlement_v1.unlock_source', 'gift_purchase'); + $payerOrder = $this->withHeaders($this->headersFor($payerAnonId)) + ->getJson('/api/v0.3/orders/'.$orderNo) + ->assertOk() + ->assertJsonPath('attempt_id', null) + ->assertJsonPath('delivery.can_view_report', false) + ->assertJsonPath('delivery.report_url', null) + ->assertJsonPath('delivery.can_download_pdf', false) + ->assertJsonPath('delivery.report_pdf_url', null) + ->assertJsonMissingPath('order') + ->assertJsonMissingPath('full_report_access_v1'); + $this->assertStringNotContainsString($attemptId, $payerOrder->getContent()); + + Http::fake([ + 'https://api.weixin.qq.com/cgi-bin/token*' => Http::response([ + 'access_token' => 'gift-access-token', + 'expires_in' => 7200, + ]), + 'https://api.weixin.qq.com/xpay/query_order*' => Http::response([ + 'errcode' => 0, + 'order' => [ + 'order_id' => (string) $signData['outTradeNo'], + 'wx_order_id' => 'wx-gift-provider-trade', + 'status' => 8, + 'order_fee' => 499, + 'paid_fee' => 499, + 'refund_fee' => 499, + 'paid_time' => 1700000000, + 'update_time' => 1700000100, + ], + ]), + ]); + $this->withHeaders($this->headersFor($payerAnonId)) + ->postJson('/api/v0.3/orders/'.$orderNo.'/wechat-mini-virtual/reconcile') + ->assertOk() + ->assertJsonPath('payment_state', 'refunded') + ->assertJsonPath('grant_state', 'revoked'); + $this->assertSame('refunded', (string) DB::table('report_gift_requests')->where('id', $giftId)->value('status')); + $this->assertSame(1, DB::table('benefit_grants')->where('order_no', $orderNo)->where('status', 'revoked')->count()); + } + + public function test_gift_refund_never_revokes_a_concurrent_non_gift_entitlement(): void + { + $recipientAnonId = 'anon_gift_concurrent_recipient'; + $payerAnonId = 'anon_gift_concurrent_payer'; + $attemptId = $this->createAttempt($recipientAnonId); + $created = $this->withHeaders($this->headersFor($recipientAnonId)) + ->postJson('/api/v0.3/attempts/'.$attemptId.'/gift-requests') + ->assertCreated(); + $token = (string) $created->json('gift_request.public_token'); + $giftId = (string) $created->json('gift_request.id'); + + Http::fake([ + 'https://api.weixin.qq.com/sns/jscode2session*' => Http::response([ + 'openid' => 'openid-gift-payer', + 'session_key' => 'session-key-gift-concurrent', + ]), + ]); + $purchased = $this->withHeaders($this->headersFor($payerAnonId)) + ->postJson('/api/v0.3/report-gifts/'.$token.'/orders/wechat_mini_virtual', [ + 'idempotency_key' => 'gift-concurrent-order', + 'wx_login_code' => 'wx-login-gift-concurrent', + ]) + ->assertOk(); + $orderNo = (string) $purchased->json('order_no'); + $signData = json_decode((string) $purchased->json('pay.params.signData'), true, flags: JSON_THROW_ON_ERROR); + + $selfOrderNo = 'ord_gift_concurrent_self'; + $selfOrderId = $this->insertOrder($selfOrderNo, $attemptId, $recipientAnonId, 'billing'); + $selfGrant = app(EntitlementManager::class)->grantAttemptUnlock( + 0, + null, + $recipientAnonId, + 'MBTI_REPORT_FULL', + $attemptId, + $selfOrderNo, + 'attempt', + null, + [], + ['unlock_source' => 'self_purchase'] + ); + $this->assertTrue((bool) ($selfGrant['ok'] ?? false)); + DB::table('benefit_grants')->where('attempt_id', $attemptId)->update(['source_order_id' => $selfOrderId]); + + $handled = app(WechatMiniVirtualPaymentService::class)->handleCallback( + $this->signedCallbackRequest($this->paidCallbackPayload($orderNo, (string) $signData['outTradeNo'])) + ); + $this->assertTrue( + (bool) ($handled['ok'] ?? false), + json_encode($handled, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + $this->assertSame(0, DB::table('benefit_grants')->where('order_no', $orderNo)->count()); + $this->assertSame(1, DB::table('benefit_grants')->where('attempt_id', $attemptId)->where('status', 'active')->count()); + $concurrentGrant = DB::table('benefit_grants')->where('attempt_id', $attemptId)->first(); + $this->assertStringContainsString('"unlock_source":"self_purchase"', (string) ($concurrentGrant->meta_json ?? '')); + $this->assertStringNotContainsString('"unlock_source":"gift_purchase"', (string) ($concurrentGrant->meta_json ?? '')); + + Http::fake([ + 'https://api.weixin.qq.com/cgi-bin/token*' => Http::response([ + 'access_token' => 'gift-concurrent-access-token', + 'expires_in' => 7200, + ]), + 'https://api.weixin.qq.com/xpay/query_order*' => Http::response([ + 'errcode' => 0, + 'order' => [ + 'order_id' => (string) $signData['outTradeNo'], + 'wx_order_id' => 'wx-gift-provider-trade', + 'status' => 8, + 'order_fee' => 499, + 'paid_fee' => 499, + 'refund_fee' => 499, + 'paid_time' => 1700000000, + 'update_time' => 1700000100, + ], + ]), + ]); + $this->withHeaders($this->headersFor($payerAnonId)) + ->postJson('/api/v0.3/orders/'.$orderNo.'/wechat-mini-virtual/reconcile') + ->assertOk() + ->assertJsonPath('payment_state', 'refunded'); + + $this->assertSame('refunded', (string) DB::table('report_gift_requests')->where('id', $giftId)->value('status')); + $this->assertSame(1, DB::table('benefit_grants')->where('attempt_id', $attemptId)->where('status', 'active')->count()); + + DB::table('orders')->where('id', $selfOrderId)->update([ + 'status' => 'refunded', + 'payment_state' => 'refunded', + 'updated_at' => now(), + ]); + $revoked = app(EntitlementManager::class)->revokeByOrderNo(0, $selfOrderNo); + $this->assertTrue((bool) ($revoked['ok'] ?? false)); + $this->assertSame(0, DB::table('benefit_grants') + ->where('attempt_id', $attemptId) + ->where('status', 'active') + ->count()); + } + + public function test_repeat_gift_after_refund_reactivates_and_rebinds_the_revoked_grant(): void + { + $recipientAnonId = 'anon_gift_repeat_recipient'; + $attemptId = $this->createAttempt($recipientAnonId); + $first = $this->createAndPayGift($attemptId, $recipientAnonId, 'anon_gift_repeat_payer_one', 'repeat-one'); + $this->refundGiftOrder( + $first['order_no'], + $first['provider_order_no'], + 'anon_gift_repeat_payer_one', + 'repeat-one' + ); + + $this->assertSame(1, DB::table('benefit_grants') + ->where('attempt_id', $attemptId) + ->where('status', 'revoked') + ->count()); + + $second = $this->createAndPayGift($attemptId, $recipientAnonId, 'anon_gift_repeat_payer_two', 'repeat-two'); + + $grant = DB::table('benefit_grants')->where('attempt_id', $attemptId)->first(); + $this->assertNotNull($grant); + $this->assertSame('active', (string) $grant->status); + $this->assertSame($second['order_no'], (string) $grant->order_no); + $this->assertStringContainsString( + '"gift_request_id":"'.$second['gift_id'].'"', + (string) ($grant->meta_json ?? '') + ); + $this->withHeaders($this->headersFor($recipientAnonId)) + ->getJson('/api/v0.3/attempts/'.$attemptId.'/report-access') + ->assertOk() + ->assertJsonPath('access_level', 'full') + ->assertJsonPath('full_report_entitlement_v1.unlock_source', 'gift_purchase'); + } + + public function test_paid_gift_remains_durable_when_an_unrelated_order_is_refunded_later(): void + { + $recipientAnonId = 'anon_gift_durable_recipient'; + $payerAnonId = 'anon_gift_durable_payer'; + $attemptId = $this->createAttempt($recipientAnonId); + $created = $this->withHeaders($this->headersFor($recipientAnonId)) + ->postJson('/api/v0.3/attempts/'.$attemptId.'/gift-requests') + ->assertCreated(); + $token = (string) $created->json('gift_request.public_token'); + + Http::fake([ + 'https://api.weixin.qq.com/sns/jscode2session*' => Http::response([ + 'openid' => 'openid-gift-payer', + 'session_key' => 'session-key-durable', + ]), + ]); + $purchased = $this->withHeaders($this->headersFor($payerAnonId)) + ->postJson('/api/v0.3/report-gifts/'.$token.'/orders/wechat_mini_virtual', [ + 'idempotency_key' => 'gift-order-durable', + 'wx_login_code' => 'wx-login-durable', + ]) + ->assertOk(); + $giftOrderNo = (string) $purchased->json('order_no'); + $signData = json_decode((string) $purchased->json('pay.params.signData'), true, flags: JSON_THROW_ON_ERROR); + + $selfOrderNo = 'ord_gift_durable_self'; + $selfOrderId = $this->insertOrder($selfOrderNo, $attemptId, $recipientAnonId, 'billing'); + $selfGrant = app(EntitlementManager::class)->grantAttemptUnlock( + 0, + null, + $recipientAnonId, + 'MBTI_REPORT_FULL', + $attemptId, + $selfOrderNo, + 'attempt', + now()->addHour()->toISOString(), + [], + ['unlock_source' => 'self_purchase'] + ); + $this->assertTrue((bool) ($selfGrant['ok'] ?? false)); + DB::table('benefit_grants')->where('attempt_id', $attemptId)->update(['source_order_id' => $selfOrderId]); + + $handled = app(WechatMiniVirtualPaymentService::class)->handleCallback( + $this->signedCallbackRequest( + $this->paidCallbackPayload($giftOrderNo, (string) $signData['outTradeNo']) + ) + ); + $this->assertTrue((bool) ($handled['ok'] ?? false)); + + $revoked = app(EntitlementManager::class)->revokeByOrderNo(0, $selfOrderNo); + $this->assertTrue((bool) ($revoked['ok'] ?? false)); + $grant = DB::table('benefit_grants')->where('attempt_id', $attemptId)->first(); + $this->assertNotNull($grant); + $this->assertSame('active', (string) $grant->status); + $this->assertSame($giftOrderNo, (string) $grant->order_no); + $this->assertNull($grant->expires_at); + $this->assertStringContainsString('"unlock_source":"gift_purchase"', (string) ($grant->meta_json ?? '')); + $this->withHeaders($this->headersFor($recipientAnonId)) + ->getJson('/api/v0.3/attempts/'.$attemptId.'/report-access') + ->assertOk() + ->assertJsonPath('access_level', 'full') + ->assertJsonPath('full_report_entitlement_v1.unlock_source', 'gift_purchase'); + } + + public function test_non_terminal_payment_attempt_keeps_single_payable_gift_and_queued_refund_updates_state(): void + { + $recipientAnonId = 'anon_gift_terminal_recipient'; + $payerAnonId = 'anon_gift_terminal_payer'; + $attemptId = $this->createAttempt($recipientAnonId); + $created = $this->withHeaders($this->headersFor($recipientAnonId)) + ->postJson('/api/v0.3/attempts/'.$attemptId.'/gift-requests') + ->assertCreated(); + $token = (string) $created->json('gift_request.public_token'); + $giftId = (string) $created->json('gift_request.id'); + + Http::fake([ + 'https://api.weixin.qq.com/sns/jscode2session*' => Http::response([ + 'openid' => 'openid-gift-payer', + 'session_key' => 'session-key-terminal', + ]), + ]); + $purchased = $this->withHeaders($this->headersFor($payerAnonId)) + ->postJson('/api/v0.3/report-gifts/'.$token.'/orders/wechat_mini_virtual', [ + 'idempotency_key' => 'gift-order-terminal', + 'wx_login_code' => 'wx-login-terminal', + ]) + ->assertOk(); + $orderNo = (string) $purchased->json('order_no'); + $signData = json_decode((string) $purchased->json('pay.params.signData'), true, flags: JSON_THROW_ON_ERROR); + $order = DB::table('orders')->where('order_no', $orderNo)->first(); + $this->assertNotNull($order); + app(OrderManager::class)->transition($orderNo, 'canceled', 0, [ + 'payment_state' => 'canceled', + 'closed_at' => now(), + ]); + app(ReportGiftService::class)->releaseReservationForOrder($order, 'canceled'); + $this->assertSame('purchasing', (string) DB::table('report_gift_requests')->where('id', $giftId)->value('status')); + $this->withHeaders($this->headersFor($recipientAnonId)) + ->postJson('/api/v0.3/attempts/'.$attemptId.'/gift-requests') + ->assertConflict() + ->assertJsonPath('error_code', 'GIFT_REQUEST_ACTIVE'); + + $handled = app(WechatMiniVirtualPaymentService::class)->handleCallback( + $this->signedCallbackRequest($this->paidCallbackPayload($orderNo, (string) $signData['outTradeNo'])) + ); + $this->assertTrue( + (bool) ($handled['ok'] ?? false), + json_encode($handled, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + $this->assertSame('fulfilled', (string) DB::table('report_gift_requests')->where('id', $giftId)->value('status')); + $this->assertSame('paid', (string) DB::table('orders')->where('order_no', $orderNo)->value('payment_state')); + $this->assertSame(1, DB::table('benefit_grants') + ->where('attempt_id', $attemptId) + ->where('status', 'active') + ->count()); + + $paid = $this->createAndPayGift( + $this->createAttempt('anon_gift_queue_refund_recipient'), + 'anon_gift_queue_refund_recipient', + 'anon_gift_queue_refund_payer', + 'queue-refund' + ); + $job = new RefundOrderJob(0, $paid['order_no'], 'gift queue refund', 'corr-gift-queue-refund'); + $job->handle(app(OrderManager::class), app(EntitlementManager::class)); + + $this->assertSame('refunded', (string) DB::table('report_gift_requests') + ->where('id', $paid['gift_id']) + ->value('status')); + $this->assertSame(1, DB::table('benefit_grants') + ->where('order_no', $paid['order_no']) + ->where('status', 'revoked') + ->count()); + } + + public function test_verified_terminal_payment_attempt_releases_gift_for_replacement(): void + { + $recipientAnonId = 'anon_gift_verified_terminal_recipient'; + $attemptId = $this->createAttempt($recipientAnonId); + $created = $this->withHeaders($this->headersFor($recipientAnonId)) + ->postJson('/api/v0.3/attempts/'.$attemptId.'/gift-requests') + ->assertCreated(); + $token = (string) $created->json('gift_request.public_token'); + $giftId = (string) $created->json('gift_request.id'); + + Http::fake([ + 'https://api.weixin.qq.com/sns/jscode2session*' => Http::response([ + 'openid' => 'openid-gift-payer', + 'session_key' => 'session-key-verified-terminal', + ]), + ]); + $purchased = $this->withHeaders($this->headersFor('anon_gift_verified_terminal_payer')) + ->postJson('/api/v0.3/report-gifts/'.$token.'/orders/wechat_mini_virtual', [ + 'idempotency_key' => 'gift-order-verified-terminal', + 'wx_login_code' => 'wx-login-verified-terminal', + ]) + ->assertOk(); + $orderNo = (string) $purchased->json('order_no'); + $signData = json_decode( + (string) $purchased->json('pay.params.signData'), + true, + flags: JSON_THROW_ON_ERROR + ); + $order = DB::table('orders')->where('order_no', $orderNo)->first(); + $this->assertNotNull($order); + $paymentAttempt = app(OrderManager::class)->latestPaymentAttemptForOrder($orderNo, 0); + $this->assertNotNull($paymentAttempt); + + app(OrderManager::class)->advancePaymentAttempt((string) $paymentAttempt->id, [ + 'state' => \App\Models\PaymentAttempt::STATE_CANCELED, + 'verified_at' => now(), + ]); + app(OrderManager::class)->transition($orderNo, 'canceled', 0, [ + 'payment_state' => 'canceled', + 'closed_at' => now(), + ]); + app(ReportGiftService::class)->releaseReservationForOrder($order, 'canceled'); + + $this->assertSame('canceled', (string) DB::table('report_gift_requests') + ->where('id', $giftId) + ->value('status')); + $replacementCreated = $this->withHeaders($this->headersFor($recipientAnonId)) + ->postJson('/api/v0.3/attempts/'.$attemptId.'/gift-requests') + ->assertCreated(); + $replacementGiftId = (string) $replacementCreated->json('gift_request.id'); + $replacementToken = (string) $replacementCreated->json('gift_request.public_token'); + $replacementPurchased = $this->withHeaders($this->headersFor('anon_gift_replacement_payer')) + ->postJson('/api/v0.3/report-gifts/'.$replacementToken.'/orders/wechat_mini_virtual', [ + 'idempotency_key' => 'gift-order-replacement', + 'wx_login_code' => 'wx-login-replacement', + ]) + ->assertOk(); + $replacementOrderNo = (string) $replacementPurchased->json('order_no'); + $replacementSignData = json_decode( + (string) $replacementPurchased->json('pay.params.signData'), + true, + flags: JSON_THROW_ON_ERROR + ); + + $handled = app(WechatMiniVirtualPaymentService::class)->handleCallback( + $this->signedCallbackRequest( + $this->paidCallbackPayload($orderNo, (string) $signData['outTradeNo']) + ) + ); + $this->assertTrue( + (bool) ($handled['ok'] ?? false), + json_encode($handled, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + $this->assertSame('fulfilled', (string) DB::table('report_gift_requests') + ->where('id', $giftId) + ->value('status')); + $this->assertSame('canceled', (string) DB::table('report_gift_requests') + ->where('id', $replacementGiftId) + ->value('status')); + $this->assertSame('canceled', (string) DB::table('orders') + ->where('order_no', $replacementOrderNo) + ->value('payment_state')); + $this->assertSame(\App\Models\PaymentAttempt::STATE_CANCELED, (string) DB::table('payment_attempts') + ->where('order_no', $replacementOrderNo) + ->value('state')); + + $replacementHandled = app(WechatMiniVirtualPaymentService::class)->handleCallback( + $this->signedCallbackRequest( + $this->paidCallbackPayload( + $replacementOrderNo, + (string) $replacementSignData['outTradeNo'] + ) + ) + ); + $this->assertFalse((bool) ($replacementHandled['ok'] ?? false)); + $this->assertSame( + 'GIFT_REQUEST_NOT_PAYABLE', + (string) ($replacementHandled['error_code'] ?? $replacementHandled['error'] ?? '') + ); + $this->assertSame('fulfilled', (string) DB::table('report_gift_requests') + ->where('id', $giftId) + ->value('status')); + $this->assertSame('canceled', (string) DB::table('report_gift_requests') + ->where('id', $replacementGiftId) + ->value('status')); + $this->assertSame('canceled', (string) DB::table('orders') + ->where('order_no', $replacementOrderNo) + ->value('payment_state')); + $this->assertSame(1, DB::table('benefit_grants') + ->where('attempt_id', $attemptId) + ->where('status', 'active') + ->count()); + + } + + public function test_same_purchaser_retry_reuses_one_gift_payment_action(): void + { + $recipientAnonId = 'anon_gift_payment_retry_recipient'; + $payerAnonId = 'anon_gift_payment_retry_payer'; + $attemptId = $this->createAttempt($recipientAnonId); + $created = $this->withHeaders($this->headersFor($recipientAnonId)) + ->postJson('/api/v0.3/attempts/'.$attemptId.'/gift-requests') + ->assertCreated(); + $token = (string) $created->json('gift_request.public_token'); + + Http::fake([ + 'https://api.weixin.qq.com/sns/jscode2session*' => Http::sequence() + ->push([ + 'openid' => 'openid-gift-payment-retry-payer', + 'session_key' => 'session-key-payment-retry-one', + ]) + ->push([ + 'openid' => 'openid-gift-payment-retry-payer', + 'session_key' => 'session-key-payment-retry-two', + ]), + ]); + $payload = [ + 'idempotency_key' => 'gift-payment-action-retry', + 'wx_login_code' => 'wx-login-payment-retry', + ]; + $first = $this->withHeaders($this->headersFor($payerAnonId)) + ->postJson('/api/v0.3/report-gifts/'.$token.'/orders/wechat_mini_virtual', $payload) + ->assertOk(); + $second = $this->withHeaders($this->headersFor($payerAnonId)) + ->postJson('/api/v0.3/report-gifts/'.$token.'/orders/wechat_mini_virtual', $payload) + ->assertOk() + ->assertJsonPath('idempotent', true); + + $firstSignData = json_decode( + (string) $first->json('pay.params.signData'), + true, + flags: JSON_THROW_ON_ERROR + ); + $secondSignData = json_decode( + (string) $second->json('pay.params.signData'), + true, + flags: JSON_THROW_ON_ERROR + ); + $this->assertSame((string) $first->json('order_no'), (string) $second->json('order_no')); + $this->assertSame((string) $firstSignData['outTradeNo'], (string) $secondSignData['outTradeNo']); + $this->assertSame(1, DB::table('payment_attempts') + ->where('order_no', (string) $first->json('order_no')) + ->count()); + } + + public function test_login_exchange_failure_releases_unpayable_order_and_allows_retry(): void + { + $recipientAnonId = 'anon_gift_login_failure_recipient'; + $payerAnonId = 'anon_gift_login_failure_payer'; + $attemptId = $this->createAttempt($recipientAnonId); + $created = $this->withHeaders($this->headersFor($recipientAnonId)) + ->postJson('/api/v0.3/attempts/'.$attemptId.'/gift-requests') + ->assertCreated(); + $token = (string) $created->json('gift_request.public_token'); + $giftId = (string) $created->json('gift_request.id'); + + Http::fake(function ($request) { + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + if (($query['js_code'] ?? null) === 'valid-login-code') { + return Http::response([ + 'openid' => 'openid-gift-payer', + 'session_key' => 'session-key-login-retry', + ]); + } + + return Http::response([ + 'errcode' => 40029, + 'errmsg' => 'invalid code', + ]); + }); + $failed = $this->withHeaders($this->headersFor($payerAnonId)) + ->postJson('/api/v0.3/report-gifts/'.$token.'/orders/wechat_mini_virtual', [ + 'idempotency_key' => 'gift-login-failure', + 'wx_login_code' => 'invalid-login-code', + ]); + $failed->assertJsonPath('ok', false); + + $gift = DB::table('report_gift_requests')->where('id', $giftId)->first(); + $this->assertNotNull($gift); + $this->assertSame('pending', (string) $gift->status); + $this->assertNull($gift->purchased_order_id); + $this->assertSame('canceled', (string) DB::table('orders') + ->where('target_attempt_id', $attemptId) + ->value('payment_state')); + $this->assertSame(0, DB::table('payment_attempts')->count()); + $failedOrderNo = (string) DB::table('orders') + ->where('target_attempt_id', $attemptId) + ->value('order_no'); + + $staleOrder = DB::table('orders') + ->where('target_attempt_id', $attemptId) + ->first(); + $this->assertNotNull($staleOrder); + $blocked = app(ReportGiftService::class) + ->createWechatMiniVirtualPaymentAction($staleOrder, 'valid-login-code'); + $this->assertFalse((bool) ($blocked['ok'] ?? false)); + $this->assertSame('GIFT_REQUEST_NOT_PAYABLE', (string) ($blocked['error_code'] ?? '')); + $this->assertSame(0, DB::table('payment_attempts')->count()); + + $retry = $this->withHeaders($this->headersFor($payerAnonId)) + ->postJson('/api/v0.3/report-gifts/'.$token.'/orders/wechat_mini_virtual', [ + 'idempotency_key' => 'gift-login-failure', + 'wx_login_code' => 'valid-login-code', + ]); + $this->assertTrue((bool) $retry->json('ok'), $retry->getContent()); + $retryOrderNo = (string) $retry->json('order_no'); + $this->assertNotSame($failedOrderNo, $retryOrderNo); + $this->assertSame($payerAnonId, (string) DB::table('orders') + ->where('order_no', $retryOrderNo) + ->value('anon_id')); + $retrySignData = json_decode( + (string) $retry->json('pay.params.signData'), + true, + flags: JSON_THROW_ON_ERROR + ); + $handled = app(WechatMiniVirtualPaymentService::class)->handleCallback( + $this->signedCallbackRequest( + $this->paidCallbackPayload($retryOrderNo, (string) $retrySignData['outTradeNo']) + ) + ); + $this->assertTrue( + (bool) ($handled['ok'] ?? false), + json_encode($handled, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + } + + public function test_repair_materializes_paid_gift_after_reused_grant_naturally_expires(): void + { + $recipientAnonId = 'anon_gift_natural_expiry_recipient'; + $payerAnonId = 'anon_gift_natural_expiry_payer'; + $attemptId = $this->createAttempt($recipientAnonId); + $created = $this->withHeaders($this->headersFor($recipientAnonId)) + ->postJson('/api/v0.3/attempts/'.$attemptId.'/gift-requests') + ->assertCreated(); + $token = (string) $created->json('gift_request.public_token'); + + Http::fake([ + 'https://api.weixin.qq.com/sns/jscode2session*' => Http::response([ + 'openid' => 'openid-gift-payer', + 'session_key' => 'session-key-natural-expiry', + ]), + ]); + $purchased = $this->withHeaders($this->headersFor($payerAnonId)) + ->postJson('/api/v0.3/report-gifts/'.$token.'/orders/wechat_mini_virtual', [ + 'idempotency_key' => 'gift-natural-expiry', + 'wx_login_code' => 'wx-login-natural-expiry', + ]) + ->assertOk(); + $giftOrderNo = (string) $purchased->json('order_no'); + $signData = json_decode((string) $purchased->json('pay.params.signData'), true, flags: JSON_THROW_ON_ERROR); + + $selfOrderNo = 'ord_gift_natural_expiry_self'; + $selfOrderId = $this->insertOrder($selfOrderNo, $attemptId, $recipientAnonId, 'billing'); + $selfGrant = app(EntitlementManager::class)->grantAttemptUnlock( + 0, + null, + $recipientAnonId, + 'MBTI_REPORT_FULL', + $attemptId, + $selfOrderNo, + 'attempt', + now()->addHour()->toISOString(), + [], + ['unlock_source' => 'self_purchase'] + ); + $this->assertTrue((bool) ($selfGrant['ok'] ?? false)); + DB::table('benefit_grants')->where('attempt_id', $attemptId)->update(['source_order_id' => $selfOrderId]); + + $handled = app(WechatMiniVirtualPaymentService::class)->handleCallback( + $this->signedCallbackRequest( + $this->paidCallbackPayload($giftOrderNo, (string) $signData['outTradeNo']) + ) + ); + $this->assertTrue( + (bool) ($handled['ok'] ?? false), + json_encode($handled, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + $this->assertSame($selfOrderNo, (string) DB::table('benefit_grants') + ->where('attempt_id', $attemptId) + ->value('order_no')); + + DB::table('benefit_grants')->where('attempt_id', $attemptId)->update([ + 'expires_at' => now()->subMinute(), + 'updated_at' => now(), + ]); + $giftOrder = DB::table('orders')->where('order_no', $giftOrderNo)->first(); + $this->assertNotNull($giftOrder); + $this->assertTrue(app(OrderRepairService::class)->requiresPaidOrderRepair($giftOrder)); + $this->artisan('commerce:repair-paid-orders', [ + '--order' => $giftOrderNo, + '--older_than_minutes' => 0, + '--json' => 1, + ])->assertExitCode(0); + + $grant = DB::table('benefit_grants')->where('attempt_id', $attemptId)->first(); + $this->assertNotNull($grant); + $this->assertSame('active', (string) $grant->status); + $this->assertSame($giftOrderNo, (string) $grant->order_no); + $this->assertNull($grant->expires_at); + $this->assertStringContainsString('"unlock_source":"gift_purchase"', (string) $grant->meta_json); + } + + public function test_manual_benefit_revocation_does_not_mislabel_paid_gift_as_refunded(): void + { + $attemptId = $this->createAttempt('anon_gift_manual_revoke_recipient'); + $paid = $this->createAndPayGift( + $attemptId, + 'anon_gift_manual_revoke_recipient', + 'anon_gift_manual_revoke_payer', + 'manual-revoke' + ); + + $revoked = app(EntitlementManager::class)->revokeByOrderNo(0, $paid['order_no']); + $this->assertTrue((bool) ($revoked['ok'] ?? false)); + $this->assertSame('fulfilled', (string) DB::table('report_gift_requests') + ->where('id', $paid['gift_id']) + ->value('status')); + $this->assertSame('paid', (string) DB::table('orders') + ->where('order_no', $paid['order_no']) + ->value('payment_state')); + $this->assertSame('revoked', (string) DB::table('orders') + ->where('order_no', $paid['order_no']) + ->value('grant_state')); + $this->assertSame(1, DB::table('benefit_grants') + ->where('order_no', $paid['order_no']) + ->where('status', 'revoked') + ->count()); + + $replayedPayload = $this->paidCallbackPayload( + $paid['order_no'], + $paid['provider_order_no'] + ); + $replayedPayload['WeChatPayInfo']['TransactionId'] = 'wx-gift-manual-revoke-replay'; + $replayed = app(WechatMiniVirtualPaymentService::class)->handleCallback( + $this->signedCallbackRequest($replayedPayload) + ); + $this->assertTrue( + (bool) ($replayed['ok'] ?? false), + json_encode($replayed, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + $this->assertTrue((bool) ($replayed['duplicate'] ?? false)); + $this->assertSame('revoked', (string) DB::table('orders') + ->where('order_no', $paid['order_no']) + ->value('grant_state')); + $this->assertSame(1, DB::table('benefit_grants') + ->where('order_no', $paid['order_no']) + ->where('status', 'revoked') + ->count()); + + $giftOrder = DB::table('orders')->where('order_no', $paid['order_no'])->first(); + $this->assertNotNull($giftOrder); + $this->assertFalse(app(OrderRepairService::class)->requiresPaidOrderRepair($giftOrder)); + $this->artisan('commerce:repair-paid-orders', [ + '--order' => $paid['order_no'], + '--older_than_minutes' => 0, + '--json' => 1, + ])->assertExitCode(0); + $this->assertSame(1, DB::table('benefit_grants') + ->where('order_no', $paid['order_no']) + ->where('status', 'revoked') + ->count()); + } + + public function test_manual_gift_revocation_without_direct_grant_preserves_unrelated_entitlement(): void + { + $recipientAnonId = 'anon_gift_manual_unbound_recipient'; + $attemptId = $this->createAttempt($recipientAnonId); + $created = $this->withHeaders($this->headersFor($recipientAnonId)) + ->postJson('/api/v0.3/attempts/'.$attemptId.'/gift-requests') + ->assertCreated(); + $giftId = (string) $created->json('gift_request.id'); + $token = (string) $created->json('gift_request.public_token'); + Http::fake([ + 'https://api.weixin.qq.com/sns/jscode2session*' => Http::response([ + 'openid' => 'openid-gift-payer', + 'session_key' => 'session-key-manual-unbound-revoke', + ]), + ]); + $purchased = $this->withHeaders($this->headersFor('anon_gift_manual_unbound_payer')) + ->postJson('/api/v0.3/report-gifts/'.$token.'/orders/wechat_mini_virtual', [ + 'idempotency_key' => 'gift-order-manual-unbound-revoke', + 'wx_login_code' => 'wx-login-manual-unbound-revoke', + ]) + ->assertOk(); + $giftOrderNo = (string) $purchased->json('order_no'); + $signData = json_decode( + (string) $purchased->json('pay.params.signData'), + true, + flags: JSON_THROW_ON_ERROR + ); + + $selfOrderNo = 'ord_gift_manual_unbound_self'; + $selfOrderId = $this->insertOrder($selfOrderNo, $attemptId, $recipientAnonId, 'billing'); + $selfGrant = app(EntitlementManager::class)->grantAttemptUnlock( + 0, + null, + $recipientAnonId, + 'MBTI_REPORT_FULL', + $attemptId, + $selfOrderNo, + 'attempt', + null, + [], + ['unlock_source' => 'self_purchase'] + ); + $this->assertTrue((bool) ($selfGrant['ok'] ?? false)); + DB::table('benefit_grants')->where('attempt_id', $attemptId)->update([ + 'source_order_id' => $selfOrderId, + ]); + + $handled = app(WechatMiniVirtualPaymentService::class)->handleCallback( + $this->signedCallbackRequest( + $this->paidCallbackPayload($giftOrderNo, (string) $signData['outTradeNo']) + ) + ); + $this->assertTrue( + (bool) ($handled['ok'] ?? false), + json_encode($handled, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + $this->assertSame(0, DB::table('benefit_grants') + ->where('order_no', $giftOrderNo) + ->count()); + + $revoked = app(EntitlementManager::class)->revokeByOrderNo(0, $giftOrderNo); + $this->assertTrue((bool) ($revoked['ok'] ?? false)); + $this->assertSame(0, (int) ($revoked['revoked'] ?? -1)); + $this->assertTrue((bool) ($revoked['gift_order_without_direct_grant'] ?? false)); + $this->assertSame('fulfilled', (string) DB::table('report_gift_requests') + ->where('id', $giftId) + ->value('status')); + $this->assertSame('paid', (string) DB::table('orders') + ->where('order_no', $giftOrderNo) + ->value('payment_state')); + $this->assertSame('active', (string) DB::table('benefit_grants') + ->where('order_no', $selfOrderNo) + ->value('status')); + $this->assertSame(1, DB::table('benefit_grants') + ->where('attempt_id', $attemptId) + ->where('status', 'active') + ->count()); + + DB::table('orders')->where('id', $selfOrderId)->update([ + 'status' => 'refunded', + 'payment_state' => 'refunded', + 'updated_at' => now(), + ]); + $selfRevoked = app(EntitlementManager::class)->revokeByOrderNo(0, $selfOrderNo); + $this->assertTrue((bool) ($selfRevoked['ok'] ?? false)); + $this->assertSame(0, DB::table('benefit_grants') + ->where('attempt_id', $attemptId) + ->where('status', 'active') + ->count()); + $this->assertSame('fulfilled', (string) DB::table('report_gift_requests') + ->where('id', $giftId) + ->value('status')); + $this->assertSame('revoked', (string) DB::table('orders') + ->where('order_no', $giftOrderNo) + ->value('grant_state')); + } + + public function test_paid_order_repair_reloads_refunded_gift_inside_transaction(): void + { + $attemptId = $this->createAttempt('anon_gift_stale_repair_recipient'); + $paid = $this->createAndPayGift( + $attemptId, + 'anon_gift_stale_repair_recipient', + 'anon_gift_stale_repair_payer', + 'stale-repair' + ); + $stalePaidOrder = DB::table('orders')->where('order_no', $paid['order_no'])->first(); + $this->assertNotNull($stalePaidOrder); + + $job = new RefundOrderJob(0, $paid['order_no'], 'gift stale repair refund', 'corr-gift-stale-repair'); + $job->handle(app(OrderManager::class), app(EntitlementManager::class)); + + $repair = app(OrderRepairService::class)->repairPaidOrder($stalePaidOrder); + $this->assertTrue((bool) ($repair['ok'] ?? false)); + $this->assertTrue((bool) ($repair['skipped'] ?? false)); + $this->assertSame('payment_not_paid', (string) ($repair['reason'] ?? '')); + $this->assertSame('refunded', (string) DB::table('report_gift_requests') + ->where('id', $paid['gift_id']) + ->value('status')); + $this->assertSame(0, DB::table('benefit_grants') + ->where('attempt_id', $attemptId) + ->where('status', 'active') + ->count()); + } + + private function configureProvider(): void + { + config()->set('payments.providers.wechat_mini_virtual.enabled', true); + config()->set('report_unlock.providers.wechat_mini_virtual.available', true); + config()->set('report_unlock.providers.gift_purchase.available', true); + config()->set('report_unlock.price_cents', 499); + config()->set('report_unlock.currency', 'CNY'); + config()->set('report_unlock.sku_by_scale.MBTI', 'MBTI_REPORT_FULL'); + config()->set('payments.wechat_mini_virtual', [ + 'app_id' => 'wx-test-app', + 'app_secret' => 'wx-test-secret', + 'offer_id' => 'offer-test', + 'app_key' => 'sandbox-app-key', + 'callback_token' => 'callback-token', + 'environment' => 1, + 'mode' => 'short_series_goods', + 'product_id' => 'mbti-report-full-sandbox', + 'sku' => 'MBTI_REPORT_FULL', + 'price_cents' => 499, + 'http_timeout_seconds' => 2, + ]); + } + + private function createAttempt(string $anonId, string $scaleCode = 'MBTI'): string + { + $attempt = Attempt::query()->create([ + 'id' => (string) Str::uuid(), + 'org_id' => 0, + 'anon_id' => $anonId, + 'scale_code' => $scaleCode, + 'scale_version' => 'v0.3', + 'region' => 'CN_MAINLAND', + 'locale' => 'zh-CN', + 'question_count' => 60, + 'answers_summary_json' => ['stage' => 'seed'], + 'client_platform' => 'test', + 'channel' => 'wechat_miniapp', + 'started_at' => now()->subMinutes(3), + 'submitted_at' => now(), + 'pack_id' => $scaleCode, + 'dir_version' => 'v1', + 'content_package_version' => 'v1', + 'scoring_spec_version' => strtolower($scaleCode).'_spec_v1', + ]); + Result::query()->create([ + 'id' => (string) Str::uuid(), + 'org_id' => 0, + 'attempt_id' => (string) $attempt->id, + 'scale_code' => $scaleCode, + 'scale_version' => 'v0.3', + 'type_code' => $scaleCode === 'MBTI' ? 'INTJ-A' : 'IQ', + 'scores_json' => [], + 'scores_pct' => [], + 'axis_states' => [], + 'pack_id' => $scaleCode, + 'dir_version' => 'v1', + 'scoring_spec_version' => strtolower($scaleCode).'_spec_v1', + 'report_engine_version' => 'v1', + 'is_valid' => true, + 'computed_at' => now(), + ]); + + return (string) $attempt->id; + } + + private function seedSku(): void + { + DB::table('skus')->updateOrInsert(['sku' => 'MBTI_REPORT_FULL'], [ + 'org_id' => 0, + 'scale_code' => 'MBTI', + 'kind' => 'report_unlock', + 'unit_qty' => 1, + 'benefit_code' => 'MBTI_REPORT_FULL', + 'scope' => 'attempt', + 'price_cents' => 499, + 'currency' => 'CNY', + 'is_active' => true, + 'meta_json' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function insertOrder(string $orderNo, string $attemptId, string $anonId, string $provider): string + { + $orderId = (string) Str::uuid(); + DB::table('orders')->insert([ + 'id' => $orderId, + 'order_no' => $orderNo, + 'org_id' => 0, + 'user_id' => null, + 'anon_id' => $anonId, + 'sku' => 'MBTI_REPORT_FULL', + 'item_sku' => 'MBTI_REPORT_FULL', + 'effective_sku' => 'MBTI_REPORT_FULL', + 'quantity' => 1, + 'target_attempt_id' => $attemptId, + 'amount_cents' => 499, + 'amount_total' => 499, + 'amount_refunded' => 0, + 'currency' => 'CNY', + 'status' => 'fulfilled', + 'payment_state' => 'paid', + 'provider' => $provider, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $orderId; + } + + /** + * @return array{gift_id:string,order_no:string,provider_order_no:string} + */ + private function createAndPayGift( + string $attemptId, + string $recipientAnonId, + string $payerAnonId, + string $suffix + ): array { + $created = $this->withHeaders($this->headersFor($recipientAnonId)) + ->postJson('/api/v0.3/attempts/'.$attemptId.'/gift-requests') + ->assertCreated(); + $token = (string) $created->json('gift_request.public_token'); + $giftId = (string) $created->json('gift_request.id'); + + Http::fake([ + 'https://api.weixin.qq.com/sns/jscode2session*' => Http::response([ + 'openid' => 'openid-gift-payer', + 'session_key' => 'session-key-'.$suffix, + ]), + ]); + $purchased = $this->withHeaders($this->headersFor($payerAnonId)) + ->postJson('/api/v0.3/report-gifts/'.$token.'/orders/wechat_mini_virtual', [ + 'idempotency_key' => 'gift-order-'.$suffix, + 'wx_login_code' => 'wx-login-'.$suffix, + ]) + ->assertOk(); + $orderNo = (string) $purchased->json('order_no'); + $signData = json_decode((string) $purchased->json('pay.params.signData'), true, flags: JSON_THROW_ON_ERROR); + $providerOrderNo = (string) $signData['outTradeNo']; + $handled = app(WechatMiniVirtualPaymentService::class)->handleCallback( + $this->signedCallbackRequest($this->paidCallbackPayload($orderNo, $providerOrderNo)) + ); + $this->assertTrue( + (bool) ($handled['ok'] ?? false), + json_encode($handled, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + + return [ + 'gift_id' => $giftId, + 'order_no' => $orderNo, + 'provider_order_no' => $providerOrderNo, + ]; + } + + private function refundGiftOrder( + string $orderNo, + string $providerOrderNo, + string $payerAnonId, + string $suffix + ): void { + Http::fake([ + 'https://api.weixin.qq.com/cgi-bin/token*' => Http::response([ + 'access_token' => 'gift-access-token-'.$suffix, + 'expires_in' => 7200, + ]), + 'https://api.weixin.qq.com/xpay/query_order*' => Http::response([ + 'errcode' => 0, + 'order' => [ + 'order_id' => $providerOrderNo, + 'wx_order_id' => 'wx-gift-provider-trade-'.$suffix, + 'status' => 8, + 'order_fee' => 499, + 'paid_fee' => 499, + 'refund_fee' => 499, + 'paid_time' => 1700000000, + 'update_time' => 1700000100, + ], + ]), + ]); + $this->withHeaders($this->headersFor($payerAnonId)) + ->postJson('/api/v0.3/orders/'.$orderNo.'/wechat-mini-virtual/reconcile') + ->assertOk() + ->assertJsonPath('payment_state', 'refunded'); + } + + /** + * @return array + */ + private function headersFor(string $anonId): array + { + $token = 'fm_'.(string) Str::uuid(); + DB::table('fm_tokens')->insert([ + 'token' => $token, + 'token_hash' => hash('sha256', $token), + 'user_id' => null, + 'anon_id' => $anonId, + 'org_id' => 0, + 'role' => 'public', + 'expires_at' => now()->addDay(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return [ + 'Authorization' => 'Bearer '.$token, + 'X-Anon-Id' => $anonId, + 'X-Channel' => 'wechat_miniapp', + ]; + } + + /** + * @return array + */ + private function paidCallbackPayload(string $orderNo, string $externalOrderNo): array + { + return [ + 'ToUserName' => 'gh_gift', + 'FromUserName' => 'openid-gift-payer', + 'CreateTime' => 1700000000, + 'MsgType' => 'event', + 'Event' => 'xpay_goods_deliver_notify', + 'OpenId' => 'openid-gift-payer', + 'OutTradeNo' => $externalOrderNo, + 'Env' => 1, + 'WeChatPayInfo' => [ + 'TransactionId' => 'wx-gift-'.hash('sha256', $externalOrderNo), + 'PaidTime' => 1700000000, + ], + 'GoodsInfo' => [ + 'ProductId' => 'mbti-report-full-sandbox', + 'Quantity' => 1, + 'OrigPrice' => 499, + 'ActualPrice' => 499, + 'Attach' => $orderNo, + ], + ]; + } + + private function signedCallbackRequest(array $payload): Request + { + $timestamp = '1700000000'; + $nonce = 'nonce-gift-contract'; + $parts = ['callback-token', $timestamp, $nonce]; + sort($parts, SORT_STRING); + + return Request::create( + '/callback?'.http_build_query([ + 'timestamp' => $timestamp, + 'nonce' => $nonce, + 'signature' => sha1(implode('', $parts)), + ]), + 'POST', + [], + [], + [], + ['CONTENT_TYPE' => 'application/json'], + json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) + ); + } +} diff --git a/backend/tests/Unit/Services/BigFive/ResultPageV2/BigFiveResultPageV2CoreBodyPreviewTest.php b/backend/tests/Unit/Services/BigFive/ResultPageV2/BigFiveResultPageV2CoreBodyPreviewTest.php index 5cf49464e..13e535927 100644 --- a/backend/tests/Unit/Services/BigFive/ResultPageV2/BigFiveResultPageV2CoreBodyPreviewTest.php +++ b/backend/tests/Unit/Services/BigFive/ResultPageV2/BigFiveResultPageV2CoreBodyPreviewTest.php @@ -3854,6 +3854,89 @@ public function test_runtime_freeze_classifier_ignores_wechat_mini_virtual_payme )); } + public function test_runtime_freeze_classifier_ignores_paid_report_gifting_only(): void + { + $changed = [ + 'backend/app/Http/Controllers/API/V0_3/ReportGiftController.php', + 'backend/app/Services/Commerce/Repair/OrderRepairService.php', + 'backend/app/Services/Commerce/ReportGiftService.php', + 'backend/routes/api.php', + ]; + $routeChangedLines = [ + '+use App\Http\Controllers\API\V0_3\ReportGiftController;', + "+ Route::post('/attempts/{id}/gift-requests', [ReportGiftController::class, 'store'])", + "+ ->middleware([\App\Http\Middleware\FmTokenAuth::class, 'uuid:id'])", + "+ ->defaults('public_realm', true)", + "+ ->name('api.v0_3.attempts.gift_requests.store');", + "+ Route::get('/attempts/{id}/gift-requests/{gift_request_id}', [ReportGiftController::class, 'showOwner'])", + "+ ->middleware([\App\Http\Middleware\FmTokenAuth::class, 'uuid:id', 'uuid:gift_request_id'])", + "+ ->defaults('public_realm', true)", + "+ ->name('api.v0_3.attempts.gift_requests.show');", + "+ Route::post('/attempts/{id}/gift-requests/{gift_request_id}/cancel', [ReportGiftController::class, 'cancel'])", + "+ ->middleware([\App\Http\Middleware\FmTokenAuth::class, 'uuid:id', 'uuid:gift_request_id'])", + "+ ->defaults('public_realm', true)", + "+ ->name('api.v0_3.attempts.gift_requests.cancel');", + "+ Route::get('/report-gifts/{token}', [ReportGiftController::class, 'showPublic'])", + "+ ->where('token', '[A-Za-z0-9_-]{43}')", + "+ ->name('api.v0_3.report_gifts.show');", + '+ Route::post(', + "+ '/report-gifts/{token}/orders/wechat_mini_virtual',", + '+ [ReportGiftController::class, \'purchaseWechatMiniVirtual\']', + '+ )->middleware(\App\Http\Middleware\FmTokenAuth::class)', + "+ ->where('token', '[A-Za-z0-9_-]{43}')", + "+ ->name('api.v0_3.report_gifts.orders.wechat_mini_virtual.store');", + "- Route::get('/orders/{order_no}', 'App\\\\Http\\\\Controllers\\\\API\\\\V0_3\\\\CommerceController@getOrder')", + "+ Route::get('/orders/{order_no}', [ReportGiftController::class, 'getOrder'])", + ]; + $orderRepairServiceChangedLines = $this->paidReportGiftingOrderRepairChangedLines(); + $orderRepairServiceHunkHeaders = $this->paidReportGiftingOrderRepairHunkHeaders(); + + $this->assertSame([], $this->mbtiImpactingRuntimeChanges( + $changed, + '', + '', + routeChangedLines: $routeChangedLines, + orderRepairServiceChangedLines: $orderRepairServiceChangedLines, + orderRepairServiceHunkHeaders: $orderRepairServiceHunkHeaders, + )); + $this->assertSame( + ['backend/app/Services/Commerce/Repair/OrderRepairService.php'], + $this->mbtiImpactingRuntimeChanges( + $changed, + '', + '', + routeChangedLines: $routeChangedLines, + orderRepairServiceChangedLines: [ + ...$orderRepairServiceChangedLines, + '+ return true;', + ], + orderRepairServiceHunkHeaders: $orderRepairServiceHunkHeaders, + ), + ); + $this->assertSame( + ['backend/app/Services/Commerce/Repair/OrderRepairService.php'], + $this->mbtiImpactingRuntimeChanges( + $changed, + '', + '', + routeChangedLines: $routeChangedLines, + orderRepairServiceChangedLines: $orderRepairServiceChangedLines, + orderRepairServiceHunkHeaders: [ + '@@ -10,0 +11 @@', + ...array_slice($orderRepairServiceHunkHeaders, 1), + ], + ), + ); + $this->assertSame( + ['backend/app/Services/BigFive/ResultPageV2/BigFiveResultPageV2Service.php'], + $this->mbtiImpactingRuntimeChanges( + ['backend/app/Services/BigFive/ResultPageV2/BigFiveResultPageV2Service.php'], + '', + '', + ), + ); + } + public function test_runtime_freeze_classifier_ignores_freemium_locale_policy_files(): void { $changed = [ @@ -6722,6 +6805,8 @@ private function mbtiImpactingRuntimeChanges( ?array $soloOwnerReviewFoundationAddedFiles = null, ?array $llmsControllerChangedLines = null, ?array $generateReportSnapshotJobChangedLines = null, + ?array $orderRepairServiceChangedLines = null, + ?array $orderRepairServiceHunkHeaders = null, ): array { $impacting = []; $soloOwnerReviewFoundationAddedFileSet = array_fill_keys( @@ -7170,6 +7255,28 @@ private function mbtiImpactingRuntimeChanges( continue; } + if ( + $file === 'backend/app/Services/Commerce/Repair/OrderRepairService.php' + && $this->orderRepairServiceDiffIsPaidReportGiftingOnly( + $orderRepairServiceChangedLines ?? ( + $repoRoot !== '' && $baseRef !== '' + ? $this->changedLinesForFile($repoRoot, $baseRef, $file) + : [] + ), + $orderRepairServiceHunkHeaders ?? ( + $repoRoot !== '' && $baseRef !== '' + ? $this->hunkHeadersForFile($repoRoot, $baseRef, $file) + : [] + ), + ) + ) { + continue; + } + + if ($this->isPaidReportGiftingFile($file)) { + continue; + } + if ($this->isCmsLifecycleTenantScopeFile($file)) { continue; } @@ -7839,6 +7946,15 @@ private function mbtiImpactingRuntimeChanges( continue; } + if ( + $file === 'backend/routes/api.php' + && $this->routeDiffIsPaidReportGiftingOnly( + $routeChangedLines ?? $this->routeChangedLines($repoRoot, $baseRef) + ) + ) { + continue; + } + if ( $file === 'backend/routes/api.php' && $this->routeDiffIsPublicApiCacheHeadersOnly($routeChangedLines ?? $this->routeChangedLines($repoRoot, $baseRef)) @@ -9544,6 +9660,189 @@ private function isCommercePaymentActionFile(string $file): bool ], true); } + private function isPaidReportGiftingFile(string $file): bool + { + return in_array($file, [ + 'backend/app/Http/Controllers/API/V0_3/ReportGiftController.php', + 'backend/app/Services/Commerce/ReportGiftService.php', + ], true); + } + + /** + * @param list $changedLines + * @param list $hunkHeaders + */ + private function orderRepairServiceDiffIsPaidReportGiftingOnly( + array $changedLines, + array $hunkHeaders + ): bool { + return $changedLines === $this->paidReportGiftingOrderRepairChangedLines() + && $hunkHeaders === $this->paidReportGiftingOrderRepairHunkHeaders(); + } + + /** + * @return list + */ + private function paidReportGiftingOrderRepairHunkHeaders(): array + { + return [ + '@@ -9,0 +10 @@', + '@@ -33 +34,14 @@ public function repairPaidOrder(object $order, array $context = []): array', + '@@ -41,0 +56,8 @@ public function repairPaidOrder(object $order, array $context = []): array', + '@@ -69,2 +91,18 @@ public function repairPaidOrder(object $order, array $context = []): array', + '@@ -72,2 +110,2 @@ public function repairPaidOrder(object $order, array $context = []): array', + '@@ -112,2 +150,2 @@ public function repairPaidOrder(object $order, array $context = []): array', + '@@ -149,11 +187,20 @@ public function repairPaidOrder(object $order, array $context = []): array', + '@@ -221,2 +268,5 @@ public function hasActiveGrantForOrder(object $order): bool', + '@@ -234,0 +285,4 @@ public function resolveActiveGrantForOrder(object $order, ?string $benefitCode =', + '@@ -253,0 +308,3 @@ public function resolveActiveGrantForOrder(object $order, ?string $benefitCode =', + '@@ -270,0 +328,3 @@ public function requiresPaidOrderRepair(object $order): bool', + '@@ -418 +478 @@ private function emptyAttemptMeta(): array', + '@@ -429 +489 @@ private function reloadOrder(object $order): ?object', + '@@ -431,2 +491,6 @@ private function reloadOrder(object $order): ?object', + '@@ -435 +499 @@ private function reloadOrder(object $order): ?object', + '@@ -437,2 +501,11 @@ private function reloadOrder(object $order): ?object', + ]; + } + + /** + * @return list + */ + private function paidReportGiftingOrderRepairChangedLines(): array + { + $diff = <<<'DIFF' ++use App\Services\Commerce\ReportGiftService; +- $freshOrder = $this->reloadOrder($order); ++ return DB::transaction( ++ fn (): array => $this->repairPaidOrderLocked($order, $context), ++ 3 ++ ); ++ } ++ ++ /** ++ * @param Order|object $order ++ * @param array $context ++ * @return array ++ */ ++ private function repairPaidOrderLocked(object $order, array $context): array ++ { ++ $freshOrder = $this->reloadOrder($order, true); ++ if ($this->isIntentionallyRevoked($freshOrder)) { ++ return $this->skip( ++ 'GRANT_INTENTIONALLY_REVOKED', ++ 'order grant was intentionally revoked.', ++ $freshOrder, ++ $context ++ ); ++ } +- $ownerGuard = $this->validateAttemptOwnershipForOrder($freshOrder, $attemptMeta); +- if (! ($ownerGuard['ok'] ?? false)) { ++ $giftService = app(ReportGiftService::class); ++ $giftRequest = $giftService->findBoundGiftForOrder($freshOrder, true); ++ if ($giftRequest !== null) { ++ $restored = $giftService->restoreTerminalGiftForVerifiedPayment($giftRequest, $freshOrder); ++ if (! ($restored['ok'] ?? false)) { ++ return $this->failure( ++ (string) ($restored['error'] ?? 'GIFT_REQUEST_NOT_PAYABLE'), ++ (string) ($restored['message'] ?? 'gift request is not payable.'), ++ $freshOrder, ++ $context ++ ); ++ } ++ $giftRequest = $giftService->findBoundGiftForOrder($freshOrder, true); ++ } ++ $ownershipGuard = $giftRequest !== null ++ ? $giftService->validateBoundGiftOrder($giftRequest, $freshOrder) ++ : $this->validateAttemptOwnershipForOrder($freshOrder, $attemptMeta); ++ if (! ($ownershipGuard['ok'] ?? false)) { +- (string) ($ownerGuard['error'] ?? 'ATTEMPT_OWNER_MISMATCH'), +- (string) ($ownerGuard['message'] ?? 'order owner mismatch.'), ++ (string) ($ownershipGuard['error'] ?? 'ATTEMPT_OWNER_MISMATCH'), ++ (string) ($ownershipGuard['message'] ?? 'order owner mismatch.'), +- $activeGrant = $this->resolveActiveGrantForOrder($freshOrder, $benefitCode); +- if ($activeGrant !== null) { ++ $activeGrant = $this->resolveActiveGrantForOrder($freshOrder, $benefitCode, true); ++ if ($activeGrant !== null && $giftRequest === null) { +- $grant = $this->entitlements->grantAttemptUnlock( +- (int) $freshOrder->org_id, +- $freshOrder->user_id ? (string) $freshOrder->user_id : null, +- $freshOrder->anon_id ? (string) $freshOrder->anon_id : null, +- $benefitCode, +- $attemptId, +- (string) $freshOrder->order_no, +- $scopeOverride, +- $expiresAt, +- $modulesIncluded +- ); ++ $grant = $giftRequest !== null ++ ? $giftService->grantVerifiedPaidGift( ++ $giftRequest, ++ $freshOrder, ++ $benefitCode, ++ $scopeOverride, ++ $expiresAt, ++ $modulesIncluded ?? [] ++ ) ++ : $this->entitlements->grantAttemptUnlock( ++ (int) $freshOrder->org_id, ++ $freshOrder->user_id ? (string) $freshOrder->user_id : null, ++ $freshOrder->anon_id ? (string) $freshOrder->anon_id : null, ++ $benefitCode, ++ $attemptId, ++ (string) $freshOrder->order_no, ++ $scopeOverride, ++ $expiresAt, ++ $modulesIncluded ++ ); +- public function resolveActiveGrantForOrder(object $order, ?string $benefitCode = null): ?object +- { ++ public function resolveActiveGrantForOrder( ++ object $order, ++ ?string $benefitCode = null, ++ bool $lockForUpdate = false ++ ): ?object { ++ ->where(function ($query): void { ++ $query->whereNull('expires_at') ++ ->orWhere('expires_at', '>', now()); ++ }) ++ if ($lockForUpdate) { ++ $query->lockForUpdate(); ++ } ++ if ($this->isIntentionallyRevoked($freshOrder)) { ++ return false; ++ } +- private function reloadOrder(object $order): ?object ++ private function reloadOrder(object $order, bool $lockForUpdate = false): ?object +- return DB::table('orders') ++ $query = DB::table('orders') +- ->when($orgId > 0 || isset($order->org_id), fn ($query) => $query->where('org_id', $orgId)) +- ->first(); ++ ->when($orgId > 0 || isset($order->org_id), fn ($query) => $query->where('org_id', $orgId)); ++ if ($lockForUpdate) { ++ $query->lockForUpdate(); ++ } ++ ++ return $query->first(); +- return DB::table('orders') ++ $query = DB::table('orders') +- ->where('org_id', $orgId) +- ->first(); ++ ->where('org_id', $orgId); ++ if ($lockForUpdate) { ++ $query->lockForUpdate(); ++ } ++ ++ return $query->first(); ++ } ++ ++ private function isIntentionallyRevoked(object $order): bool ++ { ++ return strtolower(trim((string) ($order->grant_state ?? ''))) === Order::GRANT_STATE_REVOKED; +DIFF; + + return explode("\n", $diff); + } + private function isFreemiumLocalePolicyFile(string $file): bool { return in_array($file, [ @@ -12343,6 +12642,39 @@ private function routeDiffIsWechatMiniVirtualPaymentOnly(array $changedLines): b ]; } + /** + * @param list $changedLines + */ + private function routeDiffIsPaidReportGiftingOnly(array $changedLines): bool + { + return array_values($changedLines) === [ + '+use App\Http\Controllers\API\V0_3\ReportGiftController;', + "+ Route::post('/attempts/{id}/gift-requests', [ReportGiftController::class, 'store'])", + "+ ->middleware([\App\Http\Middleware\FmTokenAuth::class, 'uuid:id'])", + "+ ->defaults('public_realm', true)", + "+ ->name('api.v0_3.attempts.gift_requests.store');", + "+ Route::get('/attempts/{id}/gift-requests/{gift_request_id}', [ReportGiftController::class, 'showOwner'])", + "+ ->middleware([\App\Http\Middleware\FmTokenAuth::class, 'uuid:id', 'uuid:gift_request_id'])", + "+ ->defaults('public_realm', true)", + "+ ->name('api.v0_3.attempts.gift_requests.show');", + "+ Route::post('/attempts/{id}/gift-requests/{gift_request_id}/cancel', [ReportGiftController::class, 'cancel'])", + "+ ->middleware([\App\Http\Middleware\FmTokenAuth::class, 'uuid:id', 'uuid:gift_request_id'])", + "+ ->defaults('public_realm', true)", + "+ ->name('api.v0_3.attempts.gift_requests.cancel');", + "+ Route::get('/report-gifts/{token}', [ReportGiftController::class, 'showPublic'])", + "+ ->where('token', '[A-Za-z0-9_-]{43}')", + "+ ->name('api.v0_3.report_gifts.show');", + '+ Route::post(', + "+ '/report-gifts/{token}/orders/wechat_mini_virtual',", + '+ [ReportGiftController::class, \'purchaseWechatMiniVirtual\']', + '+ )->middleware(\App\Http\Middleware\FmTokenAuth::class)', + "+ ->where('token', '[A-Za-z0-9_-]{43}')", + "+ ->name('api.v0_3.report_gifts.orders.wechat_mini_virtual.store');", + "- Route::get('/orders/{order_no}', 'App\\\\Http\\\\Controllers\\\\API\\\\V0_3\\\\CommerceController@getOrder')", + "+ Route::get('/orders/{order_no}', [ReportGiftController::class, 'getOrder'])", + ]; + } + /** * @param list $changedLines */ @@ -15355,6 +15687,30 @@ static function (string $line): ?string { ))); } + /** + * @return list + */ + private function hunkHeadersForFile(string $repoRoot, string $baseRef, string $file): array + { + $command = [ + 'git', + '-C', + $repoRoot, + 'diff', + '--unified=0', + "{$baseRef}...HEAD", + '--', + $file, + ]; + exec(implode(' ', array_map('escapeshellarg', $command)), $output, $exitCode); + $this->assertSame(0, $exitCode); + + return array_values(array_filter( + $output, + static fn (string $line): bool => str_starts_with($line, '@@ '), + )); + } + /** * @param list $changedLines */ diff --git a/docs/codex/pr-train-state.json b/docs/codex/pr-train-state.json index 69c731e8a..6b46b088e 100644 --- a/docs/codex/pr-train-state.json +++ b/docs/codex/pr-train-state.json @@ -1,4 +1,150 @@ { + "API-GIFT-01": { + "id": "API-GIFT-01", + "repo": "fap-api", + "base": "main", + "branch": "codex/api-gift-01-paid-report-gifting", + "title": "API-GIFT-01: add paid report gifting contract", + "depends_on": [ + "API-UNLOCK-01", + "API-GIFT-SCHEMA-01", + "API-VPAY-01" + ], + "status": "github_checks_pending", + "commit_sha": "472b256c42cf99c208414fe92b72aab0b124bc1f", + "pr_url": "https://github.com/fermatmind/fap-api/pull/3448", + "checks": { + "dependency": { + "status": "passed", + "evidence": "GitHub reports API-GIFT-SCHEMA-01 PR #3428 merged as fca264eac19ebc74f3b87a577fab13808fa603b4 and API-VPAY-01 PR #3441 merged as 15eb6a2fbf55630f3d1e53fd3bf891930ebcb60b; the worktree starts from that exact current origin/main." + }, + "authorization_boundary": { + "status": "passed", + "evidence": "The active FERMAT-WEAPP-THREE-CHANNEL-REPORT-UNLOCK-00-11 goal authorizes this independent backend PR. No migration, production configuration, provider secret, deployment, real order, payment, or mini-program upload is authorized." + }, + "local_validation": { + "status": "passed", + "evidence": "After the twelfth exact-head review repair, the declared focused contract suite passed 58 tests / 717 assertions; the gift contract alone passed 14 / 228; OpenAPI passed 2 / 6; the exact runtime classifier passed 3 / 13; scoped Pint passed all touched files; route:list exposed 98 v0.3 routes including the five gift routes; backend Composer strict validation, YAML/JSON parsing, git diff checks, and exact allowed-path scope passed. The complete backend/scripts/ci_verify_mbti.sh exited 0 before the final narrowly scoped privacy/retry repair with the repository test APP_KEY supplied only as process environment, including Big Five 1655 tests / 196111 assertions, Personality authority 95 / 5917, migration, auth, OpenAPI, order-security, dependency-security, webhook, attempt, and downstream gates. Regressions prove that a gift payer order response suppresses the recipient attempt id, raw order, report/PDF delivery, exact result entry, form summaries, and access hub; an unpayable released reservation can be retried by the same payer with the same client idempotency key without reusing the canceled order; a verified callback replay cannot reactivate an intentionally revoked paid gift; and shared OrderRepairService is exempted from the runtime freeze only when both exact changed lines and all zero-context hunk coordinates match. Earlier single-payable, late-payment, repair, refund, idempotency, revocation, and binding regressions remain green. Verifier-generated Big Five compiled artifacts were restored and no secret or production configuration was created." + }, + "github_required_checks": { + "status": "pending", + "evidence": null + }, + "merge_reconciliation": { + "status": "pending", + "evidence": null + } + }, + "scope_validation": { + "allowed_paths_only": true, + "local_validation_passed": true, + "github_checks_green": false, + "post_merge_revalidated": false + }, + "failure_reason": null, + "merged_at": null, + "remote_branch_deleted": false, + "local_cleanup_executed": false, + "production_write_execution": false, + "database_mutation": false, + "cms_mutation": false, + "api_runtime_change": true, + "publish_allowed": false, + "deploy_allowed": false, + "transitions": [ + { + "at": "2026-07-31T03:03:15Z", + "status": "implementation_in_progress", + "note": "Started in an isolated worktree from exact origin/main 15eb6a2fbf55630f3d1e53fd3bf891930ebcb60b after online dependency verification. Scope is the dedicated hash-token gift lifecycle, friend-owned virtual-payment order reservation, verified-paid recipient entitlement, payer isolation, concurrency/refund safety, tests, OpenAPI snapshot, and train metadata. Ordinary order ownership remains unchanged; no migration, production action, provider activation, secret access, or real payment is performed." + }, + { + "at": "2026-07-31T03:30:02Z", + "status": "local_validation_passed", + "note": "Implemented the fail-closed MBTI-only gift lifecycle and passed all declared local validation. The first complete verifier attempt exposed only the isolated worktree's missing test APP_KEY; rerunning with the repository phpunit test key as process-only environment completed exit 0. No key was generated or persisted, and all verifier-generated compiled artifacts were restored." + }, + { + "at": "2026-07-31T03:33:47Z", + "status": "local_validation_passed", + "note": "Integrated exact latest origin/main efd7f2bd0 without conflict. The committed branch diff then made the runtime-freeze classifier correctly expose four paid-gifting runtime paths that were invisible while uncommitted. Added an exact API-GIFT-only classifier predicate and route diff allowlist plus a negative control; the post-integration focused suite remains 48 / 564 and the classifier now passes 3 / 9. No broad runtime exception was added." + }, + { + "at": "2026-07-31T03:35:13Z", + "status": "github_checks_pending", + "note": "Pushed immutable implementation commit 6c1328394f5efaf84f9b5419cc1c52fdb040cf97 and opened PR #3448. Required GitHub checks, exact-head review, clean mergeability, and main-drift reconciliation remain mandatory before squash merge." + }, + { + "at": "2026-07-31T03:46:41Z", + "status": "github_checks_pending", + "note": "Exact head e73a18fd3dec8288524ebe705b3e860c148ffc04 passed all nine required checks with staging parity skipped, but review found three valid entitlement lifecycle issues and origin/main advanced by two commits. The repair keeps purchasing requests active beyond request TTL, reactivates and rebinds a revoked grant for a later paid gift, and preserves unrelated active entitlement provenance. Focused validation now passes 49 tests / 584 assertions plus the 3 / 9 classifier and scoped Pint; latest-main integration and fresh exact-head checks remain required." + }, + { + "at": "2026-07-31T03:47:55Z", + "status": "github_checks_pending", + "note": "Rebased all API-GIFT commits onto exact origin/main 012392aaf0d83375414f8eb9ad010f6c7c577714 without conflict. Post-rebase validation passed 49 tests / 584 assertions, OpenAPI 2 / 6, exact classifier 3 / 9, scoped Pint, Composer strict, YAML/JSON, diff, and exact 13-path scope. Repaired implementation head is af2bcccc94a15866677f12afb58a57d03387080f; a metadata-only commit will record it before fresh GitHub checks." + }, + { + "at": "2026-07-31T04:01:07Z", + "status": "github_checks_pending", + "note": "Exact head b9d98e85bb3aa1d0eb75ce4504db63e879e0e7af passed all nine required checks with staging parity skipped and had no main drift, but second exact-head review found three additional valid lifecycle issues. The repair makes a fulfilled gift request the durable fallback source when an unrelated paid grant is later refunded, releases purchasing reservations on terminal non-success payment states, and synchronizes gift status through shared EntitlementManager refund handling so queued refunds are covered. Focused validation now passes 51 tests / 606 assertions plus OpenAPI 2 / 6, classifier 3 / 9, and scoped Pint; fresh checks and review remain required." + }, + { + "at": "2026-07-31T04:02:20Z", + "status": "github_checks_pending", + "note": "Integrated exact latest origin/main b115c29775e02064f6e8168a6b1545e239d10718 without conflict. Post-integration focused validation remains 51 tests / 606 assertions with OpenAPI 2 / 6, classifier 3 / 9, scoped Pint, Composer strict, YAML/JSON, diff, and exact scope passing. Repaired implementation head is 9cea63edd2bafd30fdafca69b166caea7dbec1ec; fresh exact-head checks and review remain mandatory." + }, + { + "at": "2026-07-31T04:26:40Z", + "status": "github_checks_pending", + "note": "The third exact-head review found four additional valid paid-gift recovery issues. Implementation head 188ba73c4e25036a0b7efd8b08909169ed7c917c now restores a terminal gift before accepting a verified late payment while canceling only an unreserved replacement, releases reservations when payment-action creation fails before any payment attempt exists, rematerializes a fulfilled gift after a reused time-limited grant naturally expires, and marks gift status refunded only when the order is actually refunded. Declared focused validation is 54 tests / 637 assertions, the broader focused suite is 427 / 1150, OpenAPI is 2 / 6, and the complete verifier passed with Big Five 1654 / 196109 and Personality 95 / 5917. Fresh exact-head GitHub checks and review remain mandatory." + }, + { + "at": "2026-07-31T04:49:11Z", + "status": "github_checks_pending", + "note": "The fourth exact-head review found five additional paid-gift boundary issues. Implementation head 3aea0b6b77dd01cd0e0d66536377f9e1a93db696 now honors every verified late payment despite a purchasing or fulfilled replacement, scopes released-order idempotency to the payer, locks a fulfilled fallback gift and resets rebound access to permanent, and excludes expired grants from normal paid-order repair detection. Declared focused validation passes 54 tests / 648 assertions, OpenAPI 2 / 6, classifier 3 / 9, scoped Pint, Composer strict, YAML/JSON, route, diff, and exact scope; the complete verifier passes Big Five 1654 / 196109 and Personality 95 / 5917. Fresh exact-head GitHub checks and review remain mandatory." + }, + { + "at": "2026-07-31T05:33:55Z", + "status": "github_checks_pending", + "note": "The sixth exact-head review repair serializes payment-action creation under the exact gift order row lock. Same-payer retries now reuse one payment attempt and outTradeNo. Implementation head a38718e582961ed204d0f99d19ce7aa679c508c2 passes the declared suite at 57 tests / 671 assertions, gift contract at 13 / 182, OpenAPI 2 / 6, classifier 3 / 9, scoped Pint, Composer strict, YAML/JSON, 98-route, diff, and exact-scope checks; the complete verifier exits 0 with Big Five 1655 / 196111, Enneagram 10 / 375, and Personality 95 / 5917. Fresh exact-head GitHub checks and review remain mandatory." + }, + { + "at": "2026-07-31T05:56:39Z", + "status": "github_checks_pending", + "note": "The seventh exact-head review repair moves payment-action creation into the gift service transaction, locks and revalidates the exact order-to-purchasing-gift binding before provider exchange, and closes a purchasing replacement's order plus payment attempt before canceling that gift when an older verified payment arrives. After clean rebase onto origin/main e1022c5db4268fbb9eaa5fb4037386b5c2305eb0, implementation head 43d250482f0d2028c28d3394a627f94b2989672f passes the declared suite at 57 tests / 681 assertions, gift contract at 13 / 192, OpenAPI 2 / 6, classifier 3 / 9, scoped Pint, Composer strict, YAML/JSON, 98-route, diff, and exact-scope checks; the complete verifier exits 0 with Big Five 1655 / 196121, Enneagram 10 / 375, and Personality 95 / 5917. Fresh exact-head GitHub checks and review remain mandatory." + }, + { + "at": "2026-07-31T06:19:01Z", + "status": "github_checks_pending", + "note": "The eighth exact-head review repair rejects a canceled replacement's verified payment callback after another gift has fulfilled the same report, and recognizes gift ownership independently from refund state so manual gift revocation cannot revoke an unrelated attempt grant. After conflict-free rebase onto origin/main 193886e725a4a821accd70d5a5f9afb22f40bc97, implementation head 4e4d7d9c5e17736efa4c8e9b77dde26d5f45c82b retains the validated exact scope. The pre-rebase declared suite passed 58 tests / 699 assertions, gift contract 14 / 210, OpenAPI 2 / 6, classifier 3 / 9, scoped Pint, Composer strict, YAML/JSON, 98-route, diff, and exact-scope checks; the complete verifier exited 0 with Big Five 1655 / 196111 and Personality 95 / 5917. The intervening main-only change is confined to MBTI comparison content assets and its focused test; fresh post-rebase focused checks plus exact-head GitHub checks and review remain mandatory." + }, + { + "at": "2026-07-31T06:30:39Z", + "status": "github_checks_pending", + "note": "The ninth exact-head review repair excludes orders with grant_state=revoked from fulfilled-gift fallback selection. The expanded manual-revocation regression then refunds the unrelated self-purchase source and proves no active grant survives and the gift order remains intentionally revoked. Implementation head adcf1727bc4f1afed4f90d0ba52fc7b748f164bb passes the declared suite at 58 / 703, gift contract 14 / 214, OpenAPI 2 / 6, classifier 3 / 9, scoped Pint, Composer strict, diff, and exact scope. Fresh exact-head GitHub checks and review remain mandatory." + }, + { + "at": "2026-07-31T06:39:19Z", + "status": "github_checks_pending", + "note": "The tenth exact-head review repair removes shared OrderRepairService.php from the unconditional paid-gifting path exemption. It is now exempt only when its changed-lines list exactly matches this PR's gift repair diff; a negative control appends an ordinary runtime line and proves classification fails closed. Classifier head 0cc5ba7c2d8f198936f4ce51301fb8af1dbe9798 passes the declared three tests / 11 assertions and scoped Pint. Fresh exact-head GitHub checks and review remain mandatory." + }, + { + "at": "2026-07-31T06:50:48Z", + "status": "github_checks_pending", + "note": "The eleventh exact-head review repair preserves intentional gift revocation on verified success callback replay and binds the shared OrderRepairService runtime-freeze exemption to both exact changed lines and all zero-context hunk coordinates. Implementation head 29ff1ad197391372e9f2c63ec4a7674d09193e41 passes the declared suite at 58 / 707, gift contract 14 / 218, OpenAPI 2 / 6, exact classifier 3 / 13, scoped Pint, Composer strict, YAML/JSON, diff, and exact 13-path scope. Fresh exact-head GitHub checks and review remain mandatory." + }, + { + "at": "2026-07-31T07:03:58Z", + "status": "github_checks_pending", + "note": "The twelfth exact-head review repair routes owned order reads through a gift-aware privacy wrapper that delegates ordinary behavior unchanged and suppresses recipient delivery details only for gift orders. Released unpayable reservations now derive their server idempotency generation from prior same-actor gift-order count, so the same client key creates a fresh payable order instead of recovering the canceled one. Implementation head 96d36f64bda631590cdd3308b2b6272e51cfad49 passes the declared suite at 58 / 717, gift contract 14 / 228, OpenAPI 2 / 6, exact classifier 3 / 13, scoped Pint, Composer strict, YAML/JSON, route, diff, and exact 13-path scope. Fresh exact-head GitHub checks and review remain mandatory." + }, + { + "at": "2026-07-31T07:05:40Z", + "status": "github_checks_pending", + "note": "Rebased conflict-free onto exact origin/main 340af47e61dac3ad773fb265700252eadad51653 after verifying the only intervening change is a disjoint MBTI English comparison content package plus its focused test. Rebased implementation head 472b256c42cf99c208414fe92b72aab0b124bc1f retains the exact 13-path scope and passes the declared suite 58 / 717, gift contract 14 / 228, OpenAPI 2 / 6, exact classifier 3 / 13, scoped Pint, Composer strict, YAML/JSON, diff, and scope checks. Fresh exact-head GitHub checks and review remain mandatory." + } + ], + "updated_at": "2026-07-31T07:05:40Z" + }, "MBTI-CROSS-PUBLISH-51": { "id": "MBTI-CROSS-PUBLISH-51", "repo": "fap-api", diff --git a/docs/codex/pr-train.yaml b/docs/codex/pr-train.yaml index 4e9d91288..0dc6ffcad 100644 --- a/docs/codex/pr-train.yaml +++ b/docs/codex/pr-train.yaml @@ -210,6 +210,64 @@ prs: deploy_allowed: false content_authority: backend + - id: API-GIFT-01 + repo: fap-api + base: main + branch: codex/api-gift-01-paid-report-gifting + title: "API-GIFT-01: add paid report gifting contract" + status: github_checks_pending + depends_on: + - API-UNLOCK-01 + - API-GIFT-SCHEMA-01 + - API-VPAY-01 + train_scope: paid_report_gifting + scope: + - Add owner-created, high-entropy, hash-only report gift requests for the MBTI commercialization rollout. + - Expose a public token summary that contains no attempt id, recipient identity, or private result. + - Create friend-owned WeChat mini virtual-payment orders through a dedicated gift service without weakening ordinary order attempt ownership. + - Fulfil one recipient attempt entitlement only after a verified paid order, with one purchaser winner and idempotent webhook/repair handling. + - Keep the payer unable to read the recipient report and make refund handling revoke only a direct gift grant, never a concurrent non-gift entitlement. + allowed_paths: + - backend/routes/api.php + - backend/app/Http/Controllers/API/V0_3/ReportGiftController.php + - backend/app/Services/Commerce/ReportGiftService.php + - backend/app/Services/Commerce/EntitlementManager.php + - backend/app/Services/Commerce/Repair/OrderRepairService.php + - backend/app/Services/Commerce/Webhook/WebhookEntitlementService.php + - backend/config/report_unlock.php + - backend/docs/contracts/openapi.snapshot.json + - backend/tests/Feature/Commerce/PaymentRepairEngineTest.php + - backend/tests/Feature/Commerce/ReportGiftPurchaseContractTest.php + - backend/tests/Unit/Services/BigFive/ResultPageV2/BigFiveResultPageV2CoreBodyPreviewTest.php + - docs/codex/pr-train.yaml + - docs/codex/pr-train-state.json + do_not: + - Add or modify migrations, invite-unlock tables, invite completion counters, frontend code, CMS data, SKU rows, provider secrets, or production configuration. + - Loosen ordinary order target-attempt ownership, expose attempt ids or recipient results through public tokens, or grant access from share/open/register events. + - Enable IQ gifting, deploy, publish, upload the mini-program, create production orders, or perform real payments. + validation: + - cd backend && APP_ENV=testing DB_CONNECTION=sqlite DB_DATABASE=':memory:' php artisan test tests/Feature/Commerce/ReportGiftPurchaseContractTest.php tests/Feature/Commerce/WechatMiniVirtualPaymentContractTest.php tests/Feature/Commerce/PaymentWebhookIdempotencyTest.php tests/Feature/Commerce/PaymentRepairEngineTest.php tests/Feature/V0_3/AttemptReportAccessReadTest.php tests/Feature/Storage/ReportGiftRequestSchemaMigrationTest.php --no-ansi --display-warnings + - cd backend && APP_ENV=testing DB_CONNECTION=sqlite DB_DATABASE=':memory:' php artisan test tests/Feature/OpenApiDiffTest.php --no-ansi + - cd backend && APP_ENV=testing DB_CONNECTION=sqlite DB_DATABASE=':memory:' php artisan test tests/Unit/Services/BigFive/ResultPageV2/BigFiveResultPageV2CoreBodyPreviewTest.php --filter='test_runtime_paths_have_no_uncommitted_diff|test_runtime_freeze_classifier_ignores_wechat_mini_virtual_payment_only|test_runtime_freeze_classifier_ignores_paid_report_gifting_only' --no-ansi + - cd backend && vendor/bin/pint --test app/Http/Controllers/API/V0_3/ReportGiftController.php app/Services/Commerce/ReportGiftService.php app/Services/Commerce/EntitlementManager.php app/Services/Commerce/Repair/OrderRepairService.php app/Services/Commerce/Webhook/WebhookEntitlementService.php config/report_unlock.php tests/Feature/Commerce/PaymentRepairEngineTest.php tests/Feature/Commerce/ReportGiftPurchaseContractTest.php + - cd backend && php artisan route:list --path=api/v0.3 --except-vendor + - cd backend && composer validate --strict + - cd backend && bash scripts/ci_verify_mbti.sh + - ruby -e "require 'yaml'; YAML.load_file('docs/codex/pr-train.yaml'); puts 'yaml ok'" + - python3 -m json.tool docs/codex/pr-train-state.json >/dev/null + - git diff --check + - git diff --cached --check + - exact changed-file scope validation + stop_if_changed_files_outside_scope: true + merge_policy: { github_checks_required: true, squash: true, auto_merge: true } + production_write_execution: false + database_mutation: false + cms_mutation: false + api_runtime_change: true + publish_allowed: false + deploy_allowed: false + content_authority: backend + - id: CAREER-SEARCH-ENTRY-TIER-CONTRACT-01 repo: fap-api base: main