Deploy Ladill Transfer / deploy (push) Successful in 1m0s
Send grace-entered and countdown emails to owners alongside recipient grace milestones so unpaid transfers get clear wallet top-up reminders before deletion. Co-authored-by: Cursor <cursoragent@cursor.com>
171 lines
5.2 KiB
PHP
171 lines
5.2 KiB
PHP
<?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,
|
|
]);
|
|
|
|
$transfer = $transfer->fresh(['user', 'files', 'qrCode']);
|
|
app(TransferRecipientMailService::class)->notifyRecipient($transfer, 'grace');
|
|
app(TransferOwnerMailService::class)->notifyGraceEntered($transfer);
|
|
}
|
|
|
|
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'));
|
|
}
|
|
}
|