Files
ladill-transfer/app/Services/Transfer/TransferBillingService.php
T
isaaccladandCursor 7c4673adb6
Deploy Ladill Transfer / deploy (push) Successful in 44s
Add recipient email and milestone notifications on transfer create.
Recipients can be emailed the download link immediately and at optional
reminders (7/3/1 days before unavailable, or when grace period starts).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 12:38:58 +00:00

169 lines
5.1 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,
]);
app(TransferRecipientMailService::class)->notifyRecipient($transfer->fresh(), 'grace');
}
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'));
}
}