diff --git a/.beads/.gitignore b/.beads/.gitignore new file mode 100644 index 0000000..374adb8 --- /dev/null +++ b/.beads/.gitignore @@ -0,0 +1,32 @@ +# SQLite databases +*.db +*.db?* +*.db-journal +*.db-wal +*.db-shm + +# Daemon runtime files +daemon.lock +daemon.log +daemon.pid +bd.sock + +# Local version tracking (prevents upgrade notification spam after git ops) +.local_version + +# Legacy database files +db.sqlite +bd.db + +# Merge artifacts (temporary files from 3-way merge) +beads.base.jsonl +beads.base.meta.json +beads.left.jsonl +beads.left.meta.json +beads.right.jsonl +beads.right.meta.json + +# Keep JSONL exports and config (source of truth for git) +!issues.jsonl +!metadata.json +!config.json diff --git a/.beads/README.md b/.beads/README.md new file mode 100644 index 0000000..50f281f --- /dev/null +++ b/.beads/README.md @@ -0,0 +1,81 @@ +# Beads - AI-Native Issue Tracking + +Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. + +## What is Beads? + +Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. + +**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) + +## Quick Start + +### Essential Commands + +```bash +# Create new issues +bd create "Add user authentication" + +# View all issues +bd list + +# View issue details +bd show + +# Update issue status +bd update --status in_progress +bd update --status done + +# Sync with git remote +bd sync +``` + +### Working with Issues + +Issues in Beads are: +- **Git-native**: Stored in `.beads/issues.jsonl` and synced like code +- **AI-friendly**: CLI-first design works perfectly with AI coding agents +- **Branch-aware**: Issues can follow your branch workflow +- **Always in sync**: Auto-syncs with your commits + +## Why Beads? + +✨ **AI-Native Design** +- Built specifically for AI-assisted development workflows +- CLI-first interface works seamlessly with AI coding agents +- No context switching to web UIs + +🚀 **Developer Focused** +- Issues live in your repo, right next to your code +- Works offline, syncs when you push +- Fast, lightweight, and stays out of your way + +🔧 **Git Integration** +- Automatic sync with git commits +- Branch-aware issue tracking +- Intelligent JSONL merge resolution + +## Get Started with Beads + +Try Beads in your own projects: + +```bash +# Install Beads +curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash + +# Initialize in your repo +bd init + +# Create your first issue +bd create "Try out Beads" +``` + +## Learn More + +- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) +- **Quick Start Guide**: Run `bd quickstart` +- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) + +--- + +*Beads: Issue tracking that moves at the speed of thought* ⚡ diff --git a/.beads/config.yaml b/.beads/config.yaml new file mode 100644 index 0000000..f242785 --- /dev/null +++ b/.beads/config.yaml @@ -0,0 +1,62 @@ +# Beads Configuration File +# This file configures default behavior for all bd commands in this repository +# All settings can also be set via environment variables (BD_* prefix) +# or overridden with command-line flags + +# Issue prefix for this repository (used by bd init) +# If not set, bd init will auto-detect from directory name +# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. +# issue-prefix: "" + +# Use no-db mode: load from JSONL, no SQLite, write back after each command +# When true, bd will use .beads/issues.jsonl as the source of truth +# instead of SQLite database +# no-db: false + +# Disable daemon for RPC communication (forces direct database access) +# no-daemon: false + +# Disable auto-flush of database to JSONL after mutations +# no-auto-flush: false + +# Disable auto-import from JSONL when it's newer than database +# no-auto-import: false + +# Enable JSON output by default +# json: false + +# Default actor for audit trails (overridden by BD_ACTOR or --actor) +# actor: "" + +# Path to database (overridden by BEADS_DB or --db) +# db: "" + +# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) +# auto-start-daemon: true + +# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) +# flush-debounce: "5s" + +# Git branch for beads commits (bd sync will commit to this branch) +# IMPORTANT: Set this for team projects so all clones use the same sync branch. +# This setting persists across clones (unlike database config which is gitignored). +# Can also use BEADS_SYNC_BRANCH env var for local override. +# If not set, bd sync will require you to run 'bd config set sync.branch '. +# sync-branch: "beads-sync" + +# Multi-repo configuration (experimental - bd-307) +# Allows hydrating from multiple repositories and routing writes to the correct JSONL +# repos: +# primary: "." # Primary repo (where this database lives) +# additional: # Additional repos to hydrate from (read-only) +# - ~/beads-planning # Personal planning repo +# - ~/work-planning # Work planning repo + +# Integration settings (access with 'bd config get/set') +# These are stored in the database, not in this file: +# - jira.url +# - jira.project +# - linear.url +# - linear.api-key +# - github.org +# - github.repo diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/.beads/metadata.json b/.beads/metadata.json new file mode 100644 index 0000000..f71ae5c --- /dev/null +++ b/.beads/metadata.json @@ -0,0 +1,4 @@ +{ + "database": "beads.db", + "jsonl_export": "interactions.jsonl" +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes index f50f803..cadc554 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,3 +8,6 @@ CHANGELOG.md export-ignore README.md export-ignore + +# Use bd merge for beads JSONL files +.beads/issues.jsonl merge=beads diff --git a/AGENTS.md b/AGENTS.md index 81dd635..2c5d707 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -614,3 +614,29 @@ export default () => ( | decoration-slice | box-decoration-slice | | decoration-clone | box-decoration-clone | + +## Landing the Plane (Session Completion) + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + bd sync + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index cb31643..6a7c481 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -14,13 +14,17 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Checkout\CheckoutAssignmentService; use App\Services\Checkout\CheckoutSessionService; +use App\Services\Paddle\Exceptions\PaddleException; +use App\Services\Paddle\PaddleTransactionService; use App\Support\CheckoutRoutes; use App\Support\Concerns\PresentsPackages; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Str; use Inertia\Inertia; @@ -250,7 +254,14 @@ class CheckoutController extends Controller public function sessionStatus( CheckoutSessionStatusRequest $request, CheckoutSession $session, + CheckoutSessionService $sessions, + CheckoutAssignmentService $assignment, + PaddleTransactionService $transactions, ): JsonResponse { + $this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions); + + $session->refresh(); + return response()->json([ 'status' => $session->status, 'completed_at' => optional($session->completed_at)->toIso8601String(), @@ -327,4 +338,151 @@ class CheckoutController extends Controller return $price <= 0; } + + private function attemptPaddleRecovery( + CheckoutSession $session, + CheckoutSessionService $sessions, + CheckoutAssignmentService $assignment, + PaddleTransactionService $transactions + ): void { + if ($session->provider !== CheckoutSession::PROVIDER_PADDLE) { + return; + } + + if (in_array($session->status, [ + CheckoutSession::STATUS_COMPLETED, + CheckoutSession::STATUS_FAILED, + CheckoutSession::STATUS_CANCELLED, + ], true)) { + return; + } + + $metadata = $session->provider_metadata ?? []; + $lastPollAt = $metadata['paddle_poll_at'] ?? null; + $now = now(); + + if ($lastPollAt) { + try { + $lastPoll = Carbon::parse($lastPollAt); + if ($lastPoll->diffInSeconds($now) < 15) { + return; + } + } catch (\Throwable) { + // Ignore invalid timestamps. + } + } + + $checkoutId = $metadata['paddle_checkout_id'] ?? $session->paddle_checkout_id ?? null; + $transactionId = $metadata['paddle_transaction_id'] ?? $session->paddle_transaction_id ?? null; + + if (! $checkoutId && ! $transactionId) { + Log::info('[Checkout] Paddle recovery missing checkout reference, falling back to custom data scan', [ + 'session_id' => $session->id, + ]); + } + + $metadata['paddle_poll_at'] = $now->toIso8601String(); + $session->forceFill([ + 'provider_metadata' => $metadata, + ])->save(); + + try { + $transaction = $transactionId ? $transactions->retrieve($transactionId) : null; + + if (! $transaction && $checkoutId) { + $transaction = $transactions->findByCheckoutId($checkoutId); + } + + if (! $transaction) { + $transaction = $transactions->findByCustomData([ + 'checkout_session_id' => $session->id, + 'package_id' => (string) $session->package_id, + 'tenant_id' => (string) $session->tenant_id, + ]); + } + } catch (PaddleException $exception) { + Log::warning('[Checkout] Paddle recovery failed', [ + 'session_id' => $session->id, + 'checkout_id' => $checkoutId, + 'transaction_id' => $transactionId, + 'status' => $exception->status(), + 'message' => $exception->getMessage(), + ]); + + return; + } catch (\Throwable $exception) { + Log::warning('[Checkout] Paddle recovery failed', [ + 'session_id' => $session->id, + 'checkout_id' => $checkoutId, + 'transaction_id' => $transactionId, + 'message' => $exception->getMessage(), + ]); + + return; + } + + if (! $transaction) { + Log::info('[Checkout] Paddle recovery: transaction not found', [ + 'session_id' => $session->id, + 'checkout_id' => $checkoutId, + 'transaction_id' => $transactionId, + ]); + + return; + } + + $status = strtolower((string) ($transaction['status'] ?? '')); + $transactionId = $transactionId ?: ($transaction['id'] ?? null); + + if ($transactionId && $session->paddle_transaction_id !== $transactionId) { + $session->forceFill([ + 'paddle_transaction_id' => $transactionId, + ])->save(); + } + + if ($status === 'completed') { + $sessions->markProcessing($session, [ + 'paddle_status' => $status, + 'paddle_transaction_id' => $transactionId, + 'paddle_recovered_at' => $now->toIso8601String(), + ]); + + $assignment->finalise($session, [ + 'source' => 'paddle_poll', + 'provider' => CheckoutSession::PROVIDER_PADDLE, + 'provider_reference' => $transactionId, + 'payload' => $transaction, + ]); + + $sessions->markCompleted($session, $now); + + Log::info('[Checkout] Paddle session recovered via API', [ + 'session_id' => $session->id, + 'checkout_id' => $checkoutId, + 'transaction_id' => $transactionId, + ]); + + return; + } + + if (in_array($status, ['failed', 'cancelled', 'canceled'], true)) { + $sessions->markFailed($session, 'paddle_'.$status); + + Log::info('[Checkout] Paddle transaction failed', [ + 'session_id' => $session->id, + 'checkout_id' => $checkoutId, + 'transaction_id' => $transactionId, + 'status' => $status, + ]); + + return; + } + + Log::info('[Checkout] Paddle transaction pending', [ + 'session_id' => $session->id, + 'checkout_id' => $checkoutId, + 'transaction_id' => $transactionId, + 'status' => $status, + ]); + } } diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index 70ca421..d67b924 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -14,5 +14,6 @@ class VerifyCsrfToken extends Middleware protected $except = [ 'api/v1/photos/*/like', 'api/v1/events/*/upload', + 'paddle/webhook', ]; -} \ No newline at end of file +} diff --git a/app/Services/Paddle/PaddleTransactionService.php b/app/Services/Paddle/PaddleTransactionService.php index 50a7892..bf46b67 100644 --- a/app/Services/Paddle/PaddleTransactionService.php +++ b/app/Services/Paddle/PaddleTransactionService.php @@ -33,6 +33,87 @@ class PaddleTransactionService ]; } + /** + * @return array + */ + public function retrieve(string $transactionId): array + { + $response = $this->client->get("/transactions/{$transactionId}"); + $transaction = Arr::get($response, 'data'); + + return is_array($transaction) ? $transaction : (is_array($response) ? $response : []); + } + + /** + * @return array|null + */ + public function findByCheckoutId(string $checkoutId): ?array + { + $response = $this->client->get('/transactions', [ + 'checkout_id' => $checkoutId, + 'order_by' => '-created_at', + ]); + + $transactions = Arr::get($response, 'data', []); + + if (! is_array($transactions) || $transactions === []) { + return null; + } + + $first = $transactions[0] ?? null; + + return is_array($first) ? $first : null; + } + + /** + * @param array $criteria + * @return array|null + */ + public function findByCustomData(array $criteria, int $limit = 20): ?array + { + $payload = array_filter([ + 'order_by' => '-created_at', + 'per_page' => max(1, min($limit, 50)), + ], static fn ($value) => $value !== null && $value !== ''); + + $response = $this->client->get('/transactions', $payload); + $transactions = Arr::get($response, 'data', []); + + if (! is_array($transactions) || $transactions === []) { + return null; + } + + foreach ($transactions as $transaction) { + if (! is_array($transaction)) { + continue; + } + + $customData = Arr::get($transaction, 'custom_data', Arr::get($transaction, 'customData', [])); + if (! is_array($customData) || $customData === []) { + continue; + } + + $matches = true; + foreach ($criteria as $key => $value) { + if ($value === null || $value === '') { + continue; + } + + $candidate = $customData[$key] ?? null; + if ((string) $candidate !== (string) $value) { + $matches = false; + break; + } + } + + if ($matches) { + return $transaction; + } + } + + return null; + } + /** * Issue a refund for a Paddle transaction. * diff --git a/tests/Feature/Checkout/CheckoutSessionStatusTest.php b/tests/Feature/Checkout/CheckoutSessionStatusTest.php index 76cd6c4..99b87ad 100644 --- a/tests/Feature/Checkout/CheckoutSessionStatusTest.php +++ b/tests/Feature/Checkout/CheckoutSessionStatusTest.php @@ -8,6 +8,9 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Checkout\CheckoutSessionService; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Notification; use Tests\TestCase; class CheckoutSessionStatusTest extends TestCase @@ -54,4 +57,74 @@ class CheckoutSessionStatusTest extends TestCase $response->assertForbidden(); } + + public function test_session_status_recovers_completed_paddle_transaction(): void + { + $tenant = Tenant::factory()->create(); + $user = User::factory()->for($tenant)->create([ + 'pending_purchase' => true, + ]); + $package = Package::factory()->create([ + 'type' => 'endcustomer', + 'price' => 99, + ]); + + /** @var CheckoutSessionService $sessions */ + $sessions = app(CheckoutSessionService::class); + $session = $sessions->createOrResume($user, $package, [ + 'tenant' => $tenant, + ]); + $sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); + $session->forceFill([ + 'provider_metadata' => [ + 'paddle_checkout_id' => 'chk_123', + ], + ])->save(); + + config()->set([ + 'paddle.api_key' => 'test-key', + 'paddle.base_url' => 'https://paddle.test', + 'paddle.environment' => 'sandbox', + ]); + + Http::fake([ + 'https://paddle.test/*' => Http::response([ + 'data' => [ + [ + 'id' => 'txn_123', + 'status' => 'completed', + 'details' => [ + 'totals' => [ + 'currency_code' => 'EUR', + 'total' => ['amount' => 9900], + ], + ], + 'custom_data' => [ + 'checkout_session_id' => $session->id, + ], + ], + ], + ], 200), + ]); + + Mail::fake(); + Notification::fake(); + + $this->actingAs($user); + + $response = $this->getJson(route('checkout.session.status', $session)); + + $response->assertOk() + ->assertJsonPath('status', CheckoutSession::STATUS_COMPLETED); + + $this->assertDatabaseHas('checkout_sessions', [ + 'id' => $session->id, + 'status' => CheckoutSession::STATUS_COMPLETED, + ]); + + $this->assertDatabaseHas('package_purchases', [ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + ]); + } }