diff --git a/.env.example b/.env.example index 60652d8..8e7f105 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,8 @@ IDENTITY_API_URL=https://ladill.com/api IDENTITY_API_KEY_TRANSFER= TRANSFER_PRICE_PER_GB_MONTH=0.30 +TRANSFER_GRACE_PERIOD_DAYS=15 +TRANSFER_BILLING_PERIOD_DAYS=30 # 0 = no app-level file size cap (chunked uploads handle large files). TRANSFER_MAX_FILE_BYTES=0 TRANSFER_MAX_FILES=20 diff --git a/app/Console/Commands/ProcessTransferBillingCommand.php b/app/Console/Commands/ProcessTransferBillingCommand.php new file mode 100644 index 0000000..ce438fd --- /dev/null +++ b/app/Console/Commands/ProcessTransferBillingCommand.php @@ -0,0 +1,27 @@ +processDueRenewals(); + + $this->info(sprintf( + 'Transfer billing: %d renewed, %d entered grace, %d deleted.', + $result['renewed'], + $result['graced'], + $result['deleted'], + )); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/ChunkedUploadController.php b/app/Http/Controllers/Api/ChunkedUploadController.php index 1fb58af..edc90c9 100644 --- a/app/Http/Controllers/Api/ChunkedUploadController.php +++ b/app/Http/Controllers/Api/ChunkedUploadController.php @@ -140,7 +140,8 @@ class ChunkedUploadController extends Controller 'id' => $transfer->id, 'title' => $transfer->title, 'public_url' => $transfer->qrCode?->publicUrl(), - 'expires_at' => $transfer->expires_at?->toIso8601String(), + 'paid_until' => $transfer->paid_until?->toIso8601String(), + 'expires_at' => $transfer->paid_until?->toIso8601String(), 'files' => $transfer->files->map(fn ($file) => [ 'name' => $file->original_name, 'size_bytes' => $file->size_bytes, diff --git a/app/Http/Controllers/Api/TransferController.php b/app/Http/Controllers/Api/TransferController.php index a56c205..31d782e 100644 --- a/app/Http/Controllers/Api/TransferController.php +++ b/app/Http/Controllers/Api/TransferController.php @@ -69,7 +69,8 @@ class TransferController extends Controller 'id' => $transfer->id, 'title' => $transfer->title, 'public_url' => $transfer->qrCode?->publicUrl(), - 'expires_at' => $transfer->expires_at?->toIso8601String(), + 'paid_until' => $transfer->paid_until?->toIso8601String(), + 'expires_at' => $transfer->paid_until?->toIso8601String(), 'files' => $transfer->files->map(fn ($file) => [ 'name' => $file->original_name, 'size_bytes' => $file->size_bytes, diff --git a/app/Http/Controllers/Qr/AccountController.php b/app/Http/Controllers/Qr/AccountController.php index a2b9fbd..c270e09 100644 --- a/app/Http/Controllers/Qr/AccountController.php +++ b/app/Http/Controllers/Qr/AccountController.php @@ -52,10 +52,7 @@ class AccountController extends Controller $activeTransfers = Transfer::query() ->where('user_id', $user->id) - ->where('status', Transfer::STATUS_ACTIVE) - ->where(function ($query) { - $query->whereNull('expires_at')->orWhere('expires_at', '>', now()); - }) + ->accessible() ->orderByDesc('storage_bytes') ->get(); diff --git a/app/Http/Controllers/Transfer/OverviewController.php b/app/Http/Controllers/Transfer/OverviewController.php index 227d14c..ce71a14 100644 --- a/app/Http/Controllers/Transfer/OverviewController.php +++ b/app/Http/Controllers/Transfer/OverviewController.php @@ -21,7 +21,7 @@ class OverviewController extends Controller $transfers = Transfer::query() ->where('user_id', $account->id) - ->where('status', Transfer::STATUS_ACTIVE); + ->accessible(); $activeCount = (clone $transfers)->count(); $storageBytes = (int) (clone $transfers)->sum('storage_bytes'); @@ -37,14 +37,14 @@ class OverviewController extends Controller $expiringSoon = Transfer::query() ->where('user_id', $account->id) - ->where('status', Transfer::STATUS_ACTIVE) - ->whereNotNull('expires_at') - ->whereBetween('expires_at', [now(), now()->addDays(7)]) + ->accessible() + ->whereNotNull('paid_until') + ->whereBetween('paid_until', [now(), now()->addDays(7)]) ->count(); $recentTransfers = Transfer::query() ->where('user_id', $account->id) - ->where('status', Transfer::STATUS_ACTIVE) + ->accessible() ->with('qrCode') ->latest() ->limit(6) diff --git a/app/Http/Controllers/Transfer/TransferController.php b/app/Http/Controllers/Transfer/TransferController.php index 2a7292b..bd579cd 100644 --- a/app/Http/Controllers/Transfer/TransferController.php +++ b/app/Http/Controllers/Transfer/TransferController.php @@ -31,7 +31,7 @@ class TransferController extends Controller $transfers = Transfer::query() ->where('user_id', $account->id) - ->where('status', Transfer::STATUS_ACTIVE) + ->accessible() ->with(['qrCode', 'files']) ->latest() ->paginate(20); @@ -42,9 +42,9 @@ class TransferController extends Controller public function create(): View { return view('transfer.transfers.create', [ - 'defaultRetentionDays' => (int) config('transfer.default_retention_days', 30), 'maxFiles' => (int) config('transfer.max_files_per_transfer', 20), 'pricePerGb' => (float) config('transfer.price_per_gb_month', 0.30), + 'gracePeriodDays' => (int) config('transfer.grace_period_days', 15), ]); } @@ -62,7 +62,6 @@ class TransferController extends Controller 'title' => 'required|string|max:120', 'message' => 'nullable|string|max:2000', 'password' => 'nullable|string|min:4|max:64', - 'retention_days' => 'nullable|integer|min:1|max:365', 'files' => 'nullable|array', 'files.*' => $fileRules, 'upload_ids' => 'nullable|array', diff --git a/app/Models/Transfer.php b/app/Models/Transfer.php index 8615faa..68a8954 100644 --- a/app/Models/Transfer.php +++ b/app/Models/Transfer.php @@ -2,6 +2,8 @@ namespace App\Models; +use Carbon\CarbonInterface; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -10,6 +12,8 @@ class Transfer extends Model { public const STATUS_ACTIVE = 'active'; + public const STATUS_GRACE = 'grace'; + public const STATUS_EXPIRED = 'expired'; public const STATUS_DELETED = 'deleted'; @@ -22,6 +26,9 @@ class Transfer extends Model 'password_hash', 'retention_days', 'expires_at', + 'paid_until', + 'grace_ends_at', + 'last_billed_at', 'status', 'downloads_total', 'storage_bytes', @@ -30,6 +37,9 @@ class Transfer extends Model protected $casts = [ 'retention_days' => 'integer', 'expires_at' => 'datetime', + 'paid_until' => 'datetime', + 'grace_ends_at' => 'datetime', + 'last_billed_at' => 'datetime', 'downloads_total' => 'integer', 'storage_bytes' => 'integer', ]; @@ -54,17 +64,52 @@ class Transfer extends Model return $this->hasMany(TransferDownloadEvent::class); } + /** Files are available to recipients while paid or within the unpaid grace window. */ + public function isAccessible(): bool + { + if ($this->status === self::STATUS_DELETED) { + return false; + } + + if ($this->isPaid()) { + return true; + } + + return $this->isInGracePeriod(); + } + + /** @deprecated Use isAccessible() — kept for existing call sites. */ public function isActive(): bool { - if ($this->status !== self::STATUS_ACTIVE) { + return $this->isAccessible(); + } + + public function isPaid(): bool + { + if (! in_array($this->status, [self::STATUS_ACTIVE, self::STATUS_GRACE], true)) { return false; } - if ($this->expires_at && $this->expires_at->isPast()) { + return $this->paid_until instanceof CarbonInterface && $this->paid_until->isFuture(); + } + + public function isInGracePeriod(): bool + { + if ($this->status !== self::STATUS_GRACE) { return false; } - return true; + return $this->grace_ends_at instanceof CarbonInterface && $this->grace_ends_at->isFuture(); + } + + public function isPastGracePeriod(): bool + { + return $this->grace_ends_at instanceof CarbonInterface && $this->grace_ends_at->isPast(); + } + + public function billingPeriodEndsAt(): ?CarbonInterface + { + return $this->paid_until; } public function isPasswordProtected(): bool @@ -81,4 +126,27 @@ class Transfer extends Model { return round($this->storageGb() * (float) config('transfer.price_per_gb_month', 0.30), 2); } + + public function billingStatusLabel(): string + { + if ($this->isPaid()) { + return 'Active'; + } + + if ($this->isInGracePeriod()) { + return 'Payment due'; + } + + return 'Unavailable'; + } + + public function scopeAccessible(Builder $query): Builder + { + return $query + ->whereIn('status', [self::STATUS_ACTIVE, self::STATUS_GRACE]) + ->where(function (Builder $inner) { + $inner->where('paid_until', '>', now()) + ->orWhere('grace_ends_at', '>', now()); + }); + } } diff --git a/app/Services/Afia/AfiaService.php b/app/Services/Afia/AfiaService.php index 00c93b4..5519fc4 100644 --- a/app/Services/Afia/AfiaService.php +++ b/app/Services/Afia/AfiaService.php @@ -115,8 +115,9 @@ class AfiaService - Settings: notification preferences for transfer activity. Pricing: - - Storage retention is prepaid at GHS {$pricePerGb} per GB per month (metered on actual storage; no free tier). - - Wallet must have funds to keep transfers active through their retention period. + - Storage is billed monthly at GHS {$pricePerGb} per GB (metered on actual storage; no free tier). + - Transfers stay active while the wallet can pay each monthly renewal. + - If a renewal fails, files are kept for the grace period (default 15 days) before automatic deletion. Rules: - Only answer questions about Ladill Transfer — uploads, share links, QR codes, passwords, retention/expiry, downloads, storage billing, team, and wallet. diff --git a/app/Services/Transfer/TransferBillingService.php b/app/Services/Transfer/TransferBillingService.php new file mode 100644 index 0000000..3b85cce --- /dev/null +++ b/app/Services/Transfer/TransferBillingService.php @@ -0,0 +1,166 @@ +monthlyCostGhs(); + $minor = (int) round($ghs * 100); + + return max($minor, 1); + } + + /** Charge the first monthly period when a transfer is created. */ + public function chargeInitialPeriod(Transfer $transfer, User $user): void + { + $amountMinor = $this->amountMinorFor($transfer); + if (! $this->billing->canAfford($user->public_id, $amountMinor)) { + throw new RuntimeException('Insufficient wallet balance for the first month of storage. Top up your Ladill wallet and try again.'); + } + + $periodEnd = $this->nextPeriodEnd(now()); + $reference = $this->billingReference($transfer, $periodEnd); + + if (! $this->billing->debit( + $user->public_id, + $amountMinor, + (string) config('billing.service', 'transfer'), + 'transfer_storage', + $reference, + $transfer->id, + sprintf('Transfer storage: %s (%s)', $transfer->title, $transfer->storageGb().' GB'), + )) { + throw new RuntimeException('Could not debit your wallet for storage. Top up and try again.'); + } + + $this->markPaidThrough($transfer, $periodEnd); + } + + /** Attempt to renew a transfer for another month. */ + public function chargeRenewal(Transfer $transfer): bool + { + $transfer->loadMissing('user'); + $user = $transfer->user; + if ($user === null || $user->public_id === null) { + return false; + } + + $amountMinor = $this->amountMinorFor($transfer); + $base = $transfer->paid_until instanceof CarbonInterface + ? $transfer->paid_until->copy() + : now(); + $periodEnd = $this->nextPeriodEnd($base); + $reference = $this->billingReference($transfer, $periodEnd); + + if (! $this->billing->canAfford($user->public_id, $amountMinor)) { + return false; + } + + if (! $this->billing->debit( + $user->public_id, + $amountMinor, + (string) config('billing.service', 'transfer'), + 'transfer_storage_renewal', + $reference, + $transfer->id, + sprintf('Transfer storage renewal: %s', $transfer->title), + )) { + return false; + } + + $this->markPaidThrough($transfer, $periodEnd); + + return true; + } + + public function enterGracePeriod(Transfer $transfer): void + { + $graceEndsAt = now()->addDays($this->gracePeriodDays()); + + $transfer->update([ + 'status' => Transfer::STATUS_GRACE, + 'grace_ends_at' => $graceEndsAt, + 'expires_at' => $graceEndsAt, + ]); + } + + public function processDueRenewals(): array + { + $renewed = 0; + $graced = 0; + $deleted = 0; + + $due = Transfer::query() + ->whereIn('status', [Transfer::STATUS_ACTIVE, Transfer::STATUS_GRACE]) + ->whereNotNull('paid_until') + ->where('paid_until', '<=', now()) + ->with('user') + ->get(); + + foreach ($due as $transfer) { + if ($this->chargeRenewal($transfer)) { + $renewed++; + continue; + } + + if ($transfer->status !== Transfer::STATUS_GRACE) { + $this->enterGracePeriod($transfer); + $graced++; + } + } + + $expired = Transfer::query() + ->where('status', Transfer::STATUS_GRACE) + ->whereNotNull('grace_ends_at') + ->where('grace_ends_at', '<=', now()) + ->get(); + + foreach ($expired as $transfer) { + $this->transfers->delete($transfer); + $deleted++; + } + + return compact('renewed', 'graced', 'deleted'); + } + + private function markPaidThrough(Transfer $transfer, CarbonInterface $periodEnd): void + { + $transfer->update([ + 'status' => Transfer::STATUS_ACTIVE, + 'paid_until' => $periodEnd, + 'expires_at' => $periodEnd, + 'grace_ends_at' => null, + 'last_billed_at' => now(), + ]); + } + + private function nextPeriodEnd(CarbonInterface $from): CarbonInterface + { + return Carbon::instance($from)->addMonthNoOverflow(); + } + + private function billingReference(Transfer $transfer, CarbonInterface $periodEnd): string + { + return sprintf('transfer:%d:through:%s', $transfer->id, $periodEnd->format('Y-m-d')); + } +} diff --git a/app/Services/Transfer/TransferService.php b/app/Services/Transfer/TransferService.php index 8a2d65d..bfd3389 100644 --- a/app/Services/Transfer/TransferService.php +++ b/app/Services/Transfer/TransferService.php @@ -39,10 +39,9 @@ class TransferService } $maxBytes = (int) config('transfer.max_file_bytes', 0); - $retentionDays = (int) ($data['retention_days'] ?? config('transfer.default_retention_days', 30)); - $retentionDays = max(1, min(365, $retentionDays)); + $billingPeriodDays = (int) config('transfer.billing_period_days', 30); - return DB::transaction(function () use ($user, $data, $files, $maxBytes, $retentionDays) { + return DB::transaction(function () use ($user, $data, $files, $maxBytes, $billingPeriodDays) { $passwordHash = null; if (! empty($data['password'])) { $passwordHash = Hash::make((string) $data['password']); @@ -53,8 +52,7 @@ class TransferService 'title' => trim((string) $data['title']), 'message' => isset($data['message']) ? trim((string) $data['message']) : null, 'password_hash' => $passwordHash, - 'retention_days' => $retentionDays, - 'expires_at' => now()->addDays($retentionDays), + 'retention_days' => $billingPeriodDays, 'status' => Transfer::STATUS_ACTIVE, ]); @@ -82,6 +80,9 @@ class TransferService 'storage_bytes' => $totalBytes, ]); + $transfer = $transfer->fresh(['files', 'qrCode']); + app(TransferBillingService::class)->chargeInitialPeriod($transfer, $user); + return $transfer->fresh(['files', 'qrCode']); }); } diff --git a/config/transfer.php b/config/transfer.php index 9e836a5..6685e82 100644 --- a/config/transfer.php +++ b/config/transfer.php @@ -23,9 +23,13 @@ return [ // Max files per transfer. 'max_files_per_transfer' => (int) env('TRANSFER_MAX_FILES', 20), - // Default retention when not specified (days). - 'default_retention_days' => (int) env('TRANSFER_DEFAULT_RETENTION_DAYS', 30), + // Monthly billing cycle length (days) — storage is renewed each period while the wallet can pay. + 'billing_period_days' => (int) env('TRANSFER_BILLING_PERIOD_DAYS', 30), - // Retention for files uploaded via Ladill Mail (large attachments). + // Days to keep files after a failed renewal before automatic deletion. + 'grace_period_days' => (int) env('TRANSFER_GRACE_PERIOD_DAYS', 15), + + // Legacy env keys (billing is monthly until cancelled by non-payment + grace). + 'default_retention_days' => (int) env('TRANSFER_DEFAULT_RETENTION_DAYS', 30), 'mail_retention_days' => (int) env('TRANSFER_MAIL_RETENTION_DAYS', 30), ]; diff --git a/database/migrations/2026_06_09_120000_add_transfer_billing_period_columns.php b/database/migrations/2026_06_09_120000_add_transfer_billing_period_columns.php new file mode 100644 index 0000000..2b73436 --- /dev/null +++ b/database/migrations/2026_06_09_120000_add_transfer_billing_period_columns.php @@ -0,0 +1,35 @@ +timestamp('paid_until')->nullable()->after('expires_at'); + $table->timestamp('grace_ends_at')->nullable()->after('paid_until'); + $table->timestamp('last_billed_at')->nullable()->after('grace_ends_at'); + + $table->index(['status', 'paid_until']); + $table->index(['status', 'grace_ends_at']); + }); + + DB::table('transfers') + ->whereNull('paid_until') + ->whereNotNull('expires_at') + ->update(['paid_until' => DB::raw('expires_at')]); + } + + public function down(): void + { + Schema::table('transfers', function (Blueprint $table) { + $table->dropIndex(['status', 'paid_until']); + $table->dropIndex(['status', 'grace_ends_at']); + $table->dropColumn(['paid_until', 'grace_ends_at', 'last_billed_at']); + }); + } +}; diff --git a/deploy/shared.env.example b/deploy/shared.env.example index a7b2a78..67590d8 100644 --- a/deploy/shared.env.example +++ b/deploy/shared.env.example @@ -40,6 +40,8 @@ IDENTITY_API_KEY_TRANSFER= TRANSFER_PRICE_PER_GB_MONTH=0.30 TRANSFER_MAX_FILE_BYTES=0 TRANSFER_MAX_FILES=20 +TRANSFER_BILLING_PERIOD_DAYS=30 +TRANSFER_GRACE_PERIOD_DAYS=15 TRANSFER_DEFAULT_RETENTION_DAYS=30 TRANSFER_MAIL_RETENTION_DAYS=30 diff --git a/resources/views/qr/account/billing.blade.php b/resources/views/qr/account/billing.blade.php index bba14a8..ecc02e0 100644 --- a/resources/views/qr/account/billing.blade.php +++ b/resources/views/qr/account/billing.blade.php @@ -15,7 +15,7 @@ @endphp

Billing

-

Storage is metered at GHS {{ number_format($pricePerGb, 2) }} per GB per month of retention and debited from your Ladill wallet.

+

Storage is billed monthly at GHS {{ number_format($pricePerGb, 2) }} per GB while each transfer stays active. Renewals debit your Ladill wallet; unpaid transfers are kept for {{ (int) config('transfer.grace_period_days', 15) }} days before deletion.

@@ -51,8 +51,10 @@ {{ $transfer->title }}

{{ $fmtBytes($transfer->storage_bytes) }} - @if($transfer->expires_at) - · expires {{ $transfer->expires_at->format('M j, Y') }} + @if($transfer->isInGracePeriod()) + · payment due · kept until {{ $transfer->grace_ends_at?->format('M j, Y') }} + @elseif($transfer->paid_until) + · paid through {{ $transfer->paid_until->format('M j, Y') }} @endif

@@ -78,6 +80,6 @@
-

Retention is prepaid from your wallet. Keep it funded so transfers stay available through their expiry date.

+

Keep your wallet funded so monthly renewals succeed. If a renewal fails, you have {{ (int) config('transfer.grace_period_days', 15) }} days to top up before files are deleted.

diff --git a/resources/views/transfer/dashboard.blade.php b/resources/views/transfer/dashboard.blade.php index 27490b7..d08604a 100644 --- a/resources/views/transfer/dashboard.blade.php +++ b/resources/views/transfer/dashboard.blade.php @@ -58,7 +58,7 @@

Wallet balance

{{ $fmt($balanceMinor) }}

-

Storage is billed at GHS {{ number_format((float) config('transfer.price_per_gb_month', 0.30), 2) }} per GB per month from your Ladill wallet.

+

Storage is billed monthly at GHS {{ number_format((float) config('transfer.price_per_gb_month', 0.30), 2) }} per GB while your wallet can pay. Unpaid renewals keep files for {{ (int) config('transfer.grace_period_days', 15) }} days before deletion.

New transfer diff --git a/resources/views/transfer/transfers/create.blade.php b/resources/views/transfer/transfers/create.blade.php index 85e314a..1dcf0f8 100644 --- a/resources/views/transfer/transfers/create.blade.php +++ b/resources/views/transfer/transfers/create.blade.php @@ -4,7 +4,7 @@
← All transfers

New transfer

-

Upload files, set how long they stay hosted, and get a share link + QR code.

+

Upload files and get a share link + QR code. Storage is billed monthly from your Ladill wallet while the transfer stays active.

@if(session('error')) @@ -51,19 +51,16 @@ @error('files.*')

{{ $message }}

@enderror
-
-
- - -

GHS {{ number_format($pricePerGb, 2) }}/GB/month while files are stored.

-
-
- - -
+
+

Monthly storage billing

+

GHS {{ number_format($pricePerGb, 2) }} per GB per month, debited from your wallet. Files stay available while payments succeed. If a monthly renewal fails, files are kept for {{ $gracePeriodDays }} days before deletion.

+
+ +
+ +
diff --git a/resources/views/transfer/transfers/show.blade.php b/resources/views/transfer/transfers/show.blade.php index 46251da..5689b37 100644 --- a/resources/views/transfer/transfers/show.blade.php +++ b/resources/views/transfer/transfers/show.blade.php @@ -13,7 +13,16 @@
← All transfers

{{ $transfer->title }}

-

Expires {{ $transfer->expires_at?->format('M j, Y') ?? '—' }} · {{ $fmtBytes($transfer->storage_bytes) }} stored

+

+ @if($transfer->isInGracePeriod()) + Payment due · files kept until {{ $transfer->grace_ends_at?->format('M j, Y') }} + @elseif($transfer->paid_until) + Paid through {{ $transfer->paid_until->format('M j, Y') }} + @else + Billing pending + @endif + · {{ $fmtBytes($transfer->storage_bytes) }} stored · GHS {{ number_format($transfer->monthlyCostGhs(), 2) }}/mo +

@csrf diff --git a/routes/console.php b/routes/console.php index 3aede45..af126df 100644 --- a/routes/console.php +++ b/routes/console.php @@ -8,10 +8,4 @@ Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); -Schedule::command('hosting:check-node-health --all')->everyFiveMinutes()->withoutOverlapping(); -Schedule::command('hosting:sync-account-usage')->hourly()->withoutOverlapping(); -Schedule::command('hosting:recalculate-node-capacity --shared-only')->daily()->withoutOverlapping(); -Schedule::command('hosting:process-expired-accounts')->daily()->withoutOverlapping(); -Schedule::command('hosting:notify-expiring')->daily()->withoutOverlapping(); -Schedule::command('hosting:retry-pending-fulfillment')->everyFifteenMinutes()->withoutOverlapping(); -Schedule::command('ssl:renew')->daily()->withoutOverlapping(); +Schedule::command('transfer:process-billing')->daily()->withoutOverlapping(); diff --git a/tests/Concerns/FakesTransferBilling.php b/tests/Concerns/FakesTransferBilling.php new file mode 100644 index 0000000..fbc91d4 --- /dev/null +++ b/tests/Concerns/FakesTransferBilling.php @@ -0,0 +1,24 @@ + Http::response(['affordable' => true]), + $base.'/debit*' => Http::response(['ok' => true]), + $base.'/balance*' => Http::response(['balance_minor' => 100000]), + $base.'/service-ledger*' => Http::response([ + 'spent_minor' => 0, + 'credited_minor' => 0, + 'net_minor' => 0, + ]), + ]); + } +} diff --git a/tests/Feature/TransferApiTest.php b/tests/Feature/TransferApiTest.php index ff248cf..741f114 100644 --- a/tests/Feature/TransferApiTest.php +++ b/tests/Feature/TransferApiTest.php @@ -8,16 +8,19 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use Tests\Concerns\FakesTransferBilling; use Tests\TestCase; class TransferApiTest extends TestCase { + use FakesTransferBilling; use RefreshDatabase; protected function setUp(): void { parent::setUp(); Storage::fake('qr'); + $this->fakeTransferBillingApi(); config([ 'transfer.service_api_keys' => ['webmail' => 'test-webmail-key'], 'identity.api_url' => 'https://ladill.com/api', diff --git a/tests/Feature/TransferBillingTest.php b/tests/Feature/TransferBillingTest.php new file mode 100644 index 0000000..af020ca --- /dev/null +++ b/tests/Feature/TransferBillingTest.php @@ -0,0 +1,110 @@ + (string) Str::uuid(), + 'name' => 'Grace User', + 'email' => 'grace+'.uniqid().'@example.com', + ]); + + $transfer = Transfer::create([ + 'user_id' => $user->id, + 'title' => 'Due soon', + 'retention_days' => 30, + 'paid_until' => now()->subMinute(), + 'status' => Transfer::STATUS_ACTIVE, + 'storage_bytes' => 1024 * 1024 * 1024, + ]); + + $base = rtrim((string) config('billing.api_url'), '/'); + Http::fake([ + $base.'/can-afford*' => Http::response(['affordable' => false]), + ]); + + $result = app(TransferBillingService::class)->processDueRenewals(); + + $transfer->refresh(); + $this->assertSame(1, $result['graced']); + $this->assertSame(Transfer::STATUS_GRACE, $transfer->status); + $this->assertNotNull($transfer->grace_ends_at); + $this->assertTrue($transfer->grace_ends_at->isFuture()); + $this->assertTrue($transfer->isAccessible()); + } + + public function test_transfer_past_grace_period_is_deleted(): void + { + $user = User::create([ + 'public_id' => (string) Str::uuid(), + 'name' => 'Delete User', + 'email' => 'delete+'.uniqid().'@example.com', + ]); + + Transfer::create([ + 'user_id' => $user->id, + 'title' => 'Expired', + 'retention_days' => 30, + 'paid_until' => now()->subDays(20), + 'grace_ends_at' => now()->subMinute(), + 'status' => Transfer::STATUS_GRACE, + 'storage_bytes' => 1024, + ]); + + $base = rtrim((string) config('billing.api_url'), '/'); + Http::fake([ + $base.'/can-afford*' => Http::response(['affordable' => false]), + ]); + + $result = app(TransferBillingService::class)->processDueRenewals(); + + $this->assertSame(1, $result['deleted']); + $this->assertDatabaseHas('transfers', [ + 'title' => 'Expired', + 'status' => Transfer::STATUS_DELETED, + ]); + } + + public function test_successful_renewal_extends_paid_until(): void + { + $user = User::create([ + 'public_id' => (string) Str::uuid(), + 'name' => 'Renew User', + 'email' => 'renew+'.uniqid().'@example.com', + ]); + + $transfer = Transfer::create([ + 'user_id' => $user->id, + 'title' => 'Renew me', + 'retention_days' => 30, + 'paid_until' => now()->subMinute(), + 'status' => Transfer::STATUS_ACTIVE, + 'storage_bytes' => 1024 * 1024 * 1024, + ]); + + $this->fakeTransferBillingApi(); + + $result = app(TransferBillingService::class)->processDueRenewals(); + + $transfer->refresh(); + $this->assertSame(1, $result['renewed']); + $this->assertSame(Transfer::STATUS_ACTIVE, $transfer->status); + $this->assertTrue($transfer->paid_until->isFuture()); + $this->assertNull($transfer->grace_ends_at); + } +} diff --git a/tests/Feature/TransferTest.php b/tests/Feature/TransferTest.php index 4c2c160..14a87a3 100644 --- a/tests/Feature/TransferTest.php +++ b/tests/Feature/TransferTest.php @@ -8,15 +8,18 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use Tests\Concerns\FakesTransferBilling; use Tests\TestCase; class TransferTest extends TestCase { + use FakesTransferBilling; use RefreshDatabase; public function test_authenticated_user_can_create_transfer_with_files(): void { Storage::fake('qr'); + $this->fakeTransferBillingApi(); $user = User::create([ 'public_id' => (string) Str::uuid(), @@ -27,7 +30,6 @@ class TransferTest extends TestCase $response = $this->actingAs($user)->post(route('transfer.transfers.store'), [ 'title' => 'Project assets', 'message' => 'Here are the files.', - 'retention_days' => 14, 'files' => [ UploadedFile::fake()->create('brief.pdf', 100, 'application/pdf'), UploadedFile::fake()->create('photo.jpg', 50, 'image/jpeg'), @@ -41,7 +43,8 @@ class TransferTest extends TestCase $transfer = Transfer::first(); $this->assertNotNull($transfer->qr_code_id); - $this->assertSame(14, $transfer->retention_days); + $this->assertNotNull($transfer->paid_until); + $this->assertTrue($transfer->paid_until->isFuture()); } public function test_dashboard_requires_auth(): void