Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1e82a01
feat(commerce): add paid report gifting contract
fermatmind Jul 31, 2026
54f01e5
chore(train): record API-GIFT-01 PR
fermatmind Jul 31, 2026
c5beeaa
fix(commerce): harden report gift lifecycle
fermatmind Jul 31, 2026
2486a9c
chore(train): record API-GIFT-01 review repair
fermatmind Jul 31, 2026
db0ad43
fix(commerce): preserve durable gift entitlement sources
fermatmind Jul 31, 2026
d5332a0
chore(train): record durable gift source repair
fermatmind Jul 31, 2026
65f2d2b
fix(commerce): harden paid gift recovery
fermatmind Jul 31, 2026
7df0945
docs(codex): record third gift review repair
fermatmind Jul 31, 2026
ea12623
fix: harden paid gift recovery boundaries
fermatmind Jul 31, 2026
8a20419
docs: record fourth gift recovery repair
fermatmind Jul 31, 2026
21cda78
fix: close paid gift lifecycle race gaps
fermatmind Jul 31, 2026
50b1b8e
docs: refresh API-GIFT-01 validation evidence
fermatmind Jul 31, 2026
aeaace8
fix: serialize gift payment action creation
fermatmind Jul 31, 2026
56d66cb
docs: refresh API-GIFT-01 validation evidence
fermatmind Jul 31, 2026
f0ead6e
fix: close gift payment races
fermatmind Jul 31, 2026
dcf6abf
docs: record gift race validation
fermatmind Jul 31, 2026
56c2726
fix: reject duplicate gift settlements
fermatmind Jul 31, 2026
291d733
docs: record API-GIFT-01 review repair
fermatmind Jul 31, 2026
5493466
fix: preserve intentional gift revocation
fermatmind Jul 31, 2026
3615c4e
docs: record gift revocation repair
fermatmind Jul 31, 2026
7d99498
test: narrow gift repair freeze exemption
fermatmind Jul 31, 2026
721b76a
docs: record runtime freeze repair
fermatmind Jul 31, 2026
8231998
fix: preserve revoked gifts on callback replay
fermatmind Jul 31, 2026
625f63d
docs: record gift replay repair
fermatmind Jul 31, 2026
472b256
fix: keep gift payer delivery private
fermatmind Jul 31, 2026
74f7fca
docs: record gift privacy repair
fermatmind Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions backend/app/Http/Controllers/API/V0_3/ReportGiftController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\API\V0_3;

use App\Http\Controllers\Controller;
use App\Services\Commerce\ReportGiftService;
use App\Support\OrgContext;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class ReportGiftController extends Controller
{
public function __construct(
private readonly OrgContext $orgContext,
private readonly ReportGiftService $gifts,
) {}

public function store(Request $request, string $id): JsonResponse
{
$result = $this->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<string,mixed> $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;
}
}
103 changes: 103 additions & 0 deletions backend/app/Services/Commerce/EntitlementManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ?? ''))
Comment thread
fermatmind marked this conversation as resolved.
->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']);
Comment thread
fermatmind marked this conversation as resolved.
Comment thread
fermatmind marked this conversation as resolved.
})
->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,
Comment thread
fermatmind marked this conversation as resolved.
]);
$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)
Expand All @@ -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)
Expand Down
Loading