php-commerce/Console/PlantSubscriberTrees.php

207 lines
6.1 KiB
PHP
Raw Normal View History

2026-01-27 00:24:22 +00:00
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Console;
2026-01-27 00:24:22 +00:00
use Carbon\Carbon;
use Core\Mod\Commerce\Models\Subscription;
2026-01-27 00:24:22 +00:00
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Mod\Trees\Models\TreePlanting;
2026-01-27 00:24:22 +00:00
/**
* Plants trees for active subscribers.
*
* Part of the Trees for Agents programme. Subscribers get:
* - 1 tree/month for Starter/Pro/Creator/Agency plans
* - 2 trees/month for Enterprise plans
*
* This command is idempotent - running multiple times in the same month
* will not create duplicate tree plantings.
*/
class PlantSubscriberTrees extends Command
{
feat(commerce): implement RFC.md — billing, subscriptions, Stripe + BTCPay, Commerce Matrix (#845) Extends prior #860 DunningService with the full RFC.md surface. Lands across 44 modified/new files: * Contracts/PaymentGatewayContract.php — implemented by both Services/StripeGateway.php and Services/BTCPayGateway.php * Boot.php — provider bindings + route groups + Commerce Matrix training mode middleware * Services/WebhookService.php — DB::transaction wrapping + ProcessWebhookEvent job dispatched ->afterCommit; idempotency via webhook_events unique (gateway, event_id) — duplicates rejected silently * Jobs/ProcessWebhookEvent.php * DTOs/ — readonly PHP 8.2+ classes per RFC.dto.md * Services/SubscriptionStateMachine.php — active → suspended (failed payment) → cancelled → expired transitions * Services/ProrationService.php — credit unused old plan time, charge new plan remainder, applied via CreditNote + Invoice * DunningService extended — 1d/3d/7d/14d retry config + cancel * Migrations — guarded migrations for missing short-name billing tables (orders/payments/invoices) + RFC compatibility columns * routes/api.php — /v1/* endpoints * Checkout success/cancel routes * Commerce Matrix training-mode endpoint + record-permissions logic * Console/Commands — RFC.commands.md signatures * Events per RFC.events.md * Models extended php -l clean. composer validate passes. pest unrunnable in sandbox. Co-authored-by: Codex <noreply@openai.com> Closes tasks.lthn.sh/view.php?id=845
2026-04-25 22:55:49 +01:00
protected $signature = 'commerce:plant-trees
2026-01-27 00:24:22 +00:00
{--dry-run : Show what would be planted without actually planting}
{--force : Ignore monthly check and plant regardless}';
protected $description = 'Plant monthly trees for active subscribers';
/**
* Execute the console command.
*/
public function handle(): int
{
$dryRun = $this->option('dry-run');
$force = $this->option('force');
$month = now()->format('Y-m');
$this->info("Trees for Agents: Monthly subscriber planting for {$month}");
$this->newLine();
if ($dryRun) {
$this->warn('DRY RUN MODE - No trees will actually be planted');
$this->newLine();
}
// Get all active subscriptions
$subscriptions = Subscription::query()
->active()
->with(['workspace', 'workspacePackage.package'])
->get();
if ($subscriptions->isEmpty()) {
$this->info('No active subscriptions found.');
return self::SUCCESS;
}
$this->info("Found {$subscriptions->count()} active subscriptions");
$this->newLine();
$planted = 0;
$skipped = 0;
$errors = 0;
foreach ($subscriptions as $subscription) {
$result = $this->processSubscription($subscription, $month, $dryRun, $force);
match ($result) {
'planted' => $planted++,
'skipped' => $skipped++,
'error' => $errors++,
};
}
$this->newLine();
$this->table(
['Status', 'Count'],
[
['Planted', $planted],
['Skipped (already planted)', $skipped],
['Errors', $errors],
]
);
if ($dryRun) {
$this->newLine();
$this->warn('DRY RUN COMPLETE - No trees were actually planted');
}
return $errors > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* Process a single subscription for tree planting.
*/
protected function processSubscription(
Subscription $subscription,
string $month,
bool $dryRun,
bool $force
): string {
$workspace = $subscription->workspace;
if (! $workspace) {
$this->error(" [ERROR] Subscription #{$subscription->id} has no workspace");
return 'error';
}
// Check if already planted this month (idempotency)
if (! $force && $this->hasPlantedThisMonth($workspace->id, $month)) {
$this->line(" [SKIP] {$workspace->name} - already planted in {$month}");
return 'skipped';
}
// Determine tree count based on package tier
$trees = $this->getTreeCountForSubscription($subscription);
$packageName = $this->getPackageName($subscription);
if ($dryRun) {
$this->info(" [DRY RUN] Would plant {$trees} tree(s) for {$workspace->name} ({$packageName})");
return 'planted';
}
// Create the tree planting record
$planting = TreePlanting::create([
'provider' => null,
'model' => null,
'source' => TreePlanting::SOURCE_SUBSCRIPTION,
'trees' => $trees,
'user_id' => null,
'workspace_id' => $workspace->id,
'status' => TreePlanting::STATUS_PENDING,
'metadata' => [
'subscription_id' => $subscription->id,
'package' => $packageName,
'month' => $month,
],
]);
// Confirm the tree immediately
$planting->markConfirmed();
Log::info('Subscriber monthly tree planted', [
'tree_planting_id' => $planting->id,
'workspace_id' => $workspace->id,
'workspace_name' => $workspace->name,
'trees' => $trees,
'package' => $packageName,
'month' => $month,
]);
$this->info(" [PLANTED] {$trees} tree(s) for {$workspace->name} ({$packageName})");
return 'planted';
}
/**
* Check if this workspace has already had trees planted this month.
*/
protected function hasPlantedThisMonth(int $workspaceId, string $month): bool
{
// Parse the month string (YYYY-MM format)
$date = Carbon::createFromFormat('Y-m', $month);
2026-01-27 00:24:22 +00:00
$startOfMonth = $date->copy()->startOfMonth();
$endOfMonth = $date->copy()->endOfMonth();
return TreePlanting::query()
->where('workspace_id', $workspaceId)
->where('source', TreePlanting::SOURCE_SUBSCRIPTION)
->whereBetween('created_at', [$startOfMonth, $endOfMonth])
->exists();
}
/**
* Get the number of trees for this subscription tier.
*
* Enterprise: 2 trees/month
* All others: 1 tree/month
*/
protected function getTreeCountForSubscription(Subscription $subscription): int
{
$packageCode = $subscription->workspacePackage?->package?->code ?? '';
// Enterprise packages get 2 trees
if (str_contains(strtolower($packageCode), 'enterprise')) {
return 2;
}
return 1;
}
/**
* Get the package name for display.
*/
protected function getPackageName(Subscription $subscription): string
{
return $subscription->workspacePackage?->package?->name
?? $subscription->workspacePackage?->package?->code
?? 'Unknown';
}
}