Bill transfer storage monthly with a 15-day grace period before deletion.
Deploy Ladill Transfer / deploy (push) Successful in 51s
Deploy Ladill Transfer / deploy (push) Successful in 51s
Files stay available while wallet renewals succeed each month. Failed renewals keep files for TRANSFER_GRACE_PERIOD_DAYS (default 15) before automatic cleanup. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Transfer;
|
||||
|
||||
use App\Models\Transfer;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Carbon;
|
||||
use RuntimeException;
|
||||
|
||||
/** Monthly storage billing: paid while wallet debits succeed; 15-day grace then deletion. */
|
||||
class TransferBillingService
|
||||
{
|
||||
public function __construct(
|
||||
private BillingClient $billing,
|
||||
private TransferService $transfers,
|
||||
) {}
|
||||
|
||||
public function gracePeriodDays(): int
|
||||
{
|
||||
return max(1, (int) config('transfer.grace_period_days', 15));
|
||||
}
|
||||
|
||||
public function amountMinorFor(Transfer $transfer): int
|
||||
{
|
||||
$ghs = $transfer->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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user