Certified/app/Services/Billing/SubscriptionManager.php
2026-08-09 16:33:53 +02:00

390 lines
15 KiB
PHP

<?php
namespace App\Services\Billing;
use App\Enums\SubscriptionPlan;
use App\Enums\SubscriptionStatus;
use App\Models\Subscription;
use App\Models\SubscriptionInvoice;
use App\Models\User;
use App\Notifications\SubscriptionActivatedNotification;
use App\Notifications\SubscriptionDowngradedNotification;
use App\Notifications\SubscriptionPaymentDueNotification;
use App\Services\Moneybird\MoneybirdClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
/**
* The one place that talks to Moneybird and mutates Subscription rows.
* Moneybird itself has no notion of "a subscription" — this class builds
* one out of a contact, a one-off sales invoice for whatever's due right
* now, and a recurring sales invoice template for future renewals.
*/
class SubscriptionManager
{
public function __construct(private readonly MoneybirdClient $client) {}
public function changePlan(User $user, SubscriptionPlan $plan): Subscription
{
$subscription = $this->subscriptionFor($user);
return $plan === SubscriptionPlan::Free
? $this->downgradeToFree($subscription)
: $this->switchToPaidPlan($user, $subscription, $plan);
}
/**
* Stop future renewals; the current plan/limit keep working until
* current_period_ends_at, at which point the sync command downgrades
* to Free. Idempotent — deleting an already-deleted template 404s
* harmlessly (see MoneybirdClient::deleteRecurringSalesInvoice()).
*/
public function cancel(User $user): Subscription
{
$subscription = $this->subscriptionFor($user);
if (! $subscription->plan->isPaid()) {
return $subscription;
}
if (filled($subscription->moneybird_recurring_invoice_id)) {
$this->client->deleteRecurringSalesInvoice($subscription->moneybird_recurring_invoice_id);
}
$subscription->moneybird_recurring_invoice_id = null;
$subscription->cancel_at_period_end = true;
$subscription->save();
return $subscription;
}
/**
* Undo a pending cancellation while the current paid period hasn't
* ended yet, recreating the recurring template anchored at the
* existing renewal date.
*/
public function resume(User $user): Subscription
{
$subscription = $this->subscriptionFor($user);
if (! $subscription->cancel_at_period_end || ! $subscription->plan->isPaid()) {
return $subscription;
}
$contactId = $subscription->moneybird_contact_id ?? $this->ensureContact($user);
$nextInvoiceDate = $subscription->current_period_ends_at ?? today()->addYear();
$recurring = $this->client->createRecurringSalesInvoice(
$this->recurringPayload($contactId, $subscription->plan, $nextInvoiceDate)
);
$subscription->moneybird_contact_id = $contactId;
$subscription->moneybird_recurring_invoice_id = (string) $recurring['id'];
$subscription->cancel_at_period_end = false;
$subscription->save();
return $subscription;
}
/**
* Called by the webhook controller for any sales-invoice event. Always
* re-fetches the invoice from Moneybird rather than trusting the
* webhook payload body, and matches it to a subscription via
* moneybird_contact_id — reliable regardless of the exact field
* Moneybird uses to link an invoice back to its recurring template.
*/
public function handleSalesInvoiceEvent(string $moneybirdInvoiceId): void
{
$invoice = $this->client->findSalesInvoice($moneybirdInvoiceId);
$contactId = (string) ($invoice['contact_id'] ?? '');
$subscription = blank($contactId)
? null
: Subscription::where('moneybird_contact_id', $contactId)->first();
if (! $subscription) {
Log::info('Moneybird webhook: no matching subscription for invoice.', [
'invoice_id' => $moneybirdInvoiceId,
'contact_id' => $contactId,
]);
return;
}
$isNewInvoice = ! SubscriptionInvoice::where('moneybird_invoice_id', $moneybirdInvoiceId)->exists();
$amountCents = $this->totalCentsFromInvoice($invoice) ?? $subscription->plan->priceInCents();
$record = $this->upsertInvoiceRecord($subscription, $invoice, $subscription->plan, $amountCents);
$state = $invoice['state'] ?? null;
match (true) {
$state === 'paid' => $this->activate($subscription, $record),
in_array($state, ['late', 'uncollectible'], true) => $this->markPastDue($subscription, $record),
$isNewInvoice => $subscription->user?->notify(new SubscriptionPaymentDueNotification($subscription, $record)),
default => null,
};
}
/**
* Daily safety-net sweep — webhooks are the primary path, this covers
* missed/not-yet-configured deliveries: flips overdue AwaitingPayment
* subscriptions to PastDue (starting their grace period), then
* downgrades anything whose grace period or cancellation has lapsed.
*
* @return array{pastDue: int, downgraded: int}
*/
public function syncSubscriptions(): array
{
$pastDue = 0;
$downgraded = 0;
Subscription::query()
->where('status', SubscriptionStatus::AwaitingPayment)
->whereHas('invoices', fn ($query) => $query->where('due_date', '<', today())->where('state', '!=', 'paid'))
->each(function (Subscription $subscription) use (&$pastDue) {
$subscription->status = SubscriptionStatus::PastDue;
$subscription->grace_ends_at ??= today()->addDays(config('services.moneybird.grace_days'));
$subscription->save();
$pastDue++;
});
Subscription::query()
->where('status', SubscriptionStatus::PastDue)
->whereNotNull('grace_ends_at')
->where('grace_ends_at', '<', today())
->each(fn (Subscription $subscription) => $this->downgradeAndNotify($subscription, $downgraded));
Subscription::query()
->where('status', SubscriptionStatus::Active)
->where('cancel_at_period_end', true)
->whereNotNull('current_period_ends_at')
->where('current_period_ends_at', '<', today())
->each(fn (Subscription $subscription) => $this->downgradeAndNotify($subscription, $downgraded));
return ['pastDue' => $pastDue, 'downgraded' => $downgraded];
}
private function downgradeAndNotify(Subscription $subscription, int &$downgraded): void
{
$this->downgradeToFree($subscription);
$subscription->user?->notify(new SubscriptionDowngradedNotification($subscription));
$downgraded++;
}
private function subscriptionFor(User $user): Subscription
{
return $user->subscription ?? $user->subscription()->create();
}
private function downgradeToFree(Subscription $subscription): Subscription
{
if (filled($subscription->moneybird_recurring_invoice_id)) {
$this->client->deleteRecurringSalesInvoice($subscription->moneybird_recurring_invoice_id);
}
$subscription->fill([
'plan' => SubscriptionPlan::Free,
'status' => SubscriptionStatus::Active,
'moneybird_recurring_invoice_id' => null,
'current_period_starts_at' => null,
'current_period_ends_at' => null,
'grace_ends_at' => null,
'cancel_at_period_end' => false,
])->save();
return $subscription;
}
private function switchToPaidPlan(User $user, Subscription $subscription, SubscriptionPlan $plan): Subscription
{
$contactId = $subscription->moneybird_contact_id ?? $this->ensureContact($user);
$isFreshSubscribe = blank($subscription->moneybird_recurring_invoice_id);
if ($isFreshSubscribe) {
$periodStart = today();
$periodEnd = $periodStart->copy()->addYear();
$invoice = $this->client->createSalesInvoice(
$this->invoicePayload($contactId, $plan, $plan->priceInCents(), $periodStart)
);
$this->upsertInvoiceRecord($subscription, $invoice, $plan, $plan->priceInCents());
$recurring = $this->client->createRecurringSalesInvoice(
$this->recurringPayload($contactId, $plan, $periodEnd)
);
$subscription->fill([
'plan' => $plan,
'status' => SubscriptionStatus::AwaitingPayment,
'moneybird_contact_id' => $contactId,
'moneybird_recurring_invoice_id' => (string) $recurring['id'],
'current_period_starts_at' => $periodStart,
'current_period_ends_at' => $periodEnd,
'grace_ends_at' => null,
'cancel_at_period_end' => false,
])->save();
return $subscription;
}
// Paid -> paid switch: bill only the prorated top-up (if any),
// keep the existing period anchor, and re-point the recurring
// template at the new plan's price for future renewals.
$diffCents = max(0, $plan->priceInCents() - $subscription->plan->priceInCents());
if ($diffCents > 0 && $subscription->current_period_ends_at) {
$remainingDays = today()->lte($subscription->current_period_ends_at)
? today()->diffInDays($subscription->current_period_ends_at)
: 0;
$proratedCents = (int) round($diffCents * min($remainingDays, 365) / 365);
if ($proratedCents > 0) {
$invoice = $this->client->createSalesInvoice(
$this->invoicePayload($contactId, $plan, $proratedCents, today(), proration: true)
);
$this->upsertInvoiceRecord($subscription, $invoice, $plan, $proratedCents);
$subscription->status = SubscriptionStatus::AwaitingPayment;
}
}
if (filled($subscription->moneybird_recurring_invoice_id)) {
$this->client->updateRecurringSalesInvoice($subscription->moneybird_recurring_invoice_id, [
'details_attributes' => [$this->lineItem($plan, $plan->priceInCents())],
]);
}
$subscription->moneybird_contact_id = $contactId;
$subscription->plan = $plan;
$subscription->save();
return $subscription;
}
private function ensureContact(User $user): string
{
$existing = $this->client->findContactByCustomerId((string) $user->id);
if ($existing) {
return (string) $existing['id'];
}
$created = $this->client->createContact([
'customer_id' => (string) $user->id,
'firstname' => $user->name,
'email' => $user->email,
'send_invoices_to_email' => $user->email,
]);
return (string) $created['id'];
}
/**
* @return array<string, mixed>
*/
private function invoicePayload(string $contactId, SubscriptionPlan $plan, int $amountCents, Carbon $invoiceDate, bool $proration = false): array
{
return [
'contact_id' => $contactId,
'invoice_date' => $invoiceDate->toDateString(),
'details_attributes' => [$this->lineItem($plan, $amountCents, $proration)],
];
}
/**
* @return array<string, mixed>
*/
private function recurringPayload(string $contactId, SubscriptionPlan $plan, Carbon $nextInvoiceDate): array
{
return [
'contact_id' => $contactId,
'invoice_date' => $nextInvoiceDate->toDateString(),
'frequency_type' => 'year',
'frequency' => 1,
// We send our own notification with the payment link instead
// of Moneybird's built-in invoice email.
'auto_send' => false,
'details_attributes' => [$this->lineItem($plan, $plan->priceInCents())],
];
}
/**
* @return array<string, mixed>
*/
private function lineItem(SubscriptionPlan $plan, int $amountCents, bool $proration = false): array
{
return array_filter([
// Invoice line text is always Dutch — it's real bookkeeping
// content in the user's Moneybird administration, not
// app UI, so it isn't run through __().
'description' => $proration
? "Bijbetaling voor upgrade naar {$plan->label()}"
: $plan->moneybirdDescription(),
'price' => number_format($amountCents / 100, 2, '.', ''),
'tax_rate_id' => config('services.moneybird.tax_rate_id'),
'ledger_account_id' => config('services.moneybird.ledger_account_id'),
], fn ($value) => $value !== null);
}
/**
* @param array<string, mixed> $invoice
*/
private function upsertInvoiceRecord(Subscription $subscription, array $invoice, SubscriptionPlan $plan, int $amountCents): SubscriptionInvoice
{
return SubscriptionInvoice::updateOrCreate(
['moneybird_invoice_id' => (string) $invoice['id']],
[
'subscription_id' => $subscription->id,
'plan' => $plan,
'amount_cents' => $amountCents,
'state' => $invoice['state'] ?? 'open',
'invoice_date' => $invoice['invoice_date'] ?? null,
'due_date' => $invoice['due_date'] ?? null,
'paid_at' => filled($invoice['paid_at'] ?? null) ? $invoice['paid_at'] : null,
'payment_url' => $invoice['payment_url'] ?? null,
],
);
}
/**
* @param array<string, mixed> $invoice
*/
private function totalCentsFromInvoice(array $invoice): ?int
{
$total = $invoice['total_price_excl_tax'] ?? null;
return $total !== null ? (int) round(((float) $total) * 100) : null;
}
private function activate(Subscription $subscription, SubscriptionInvoice $invoice): void
{
$isRenewal = $subscription->current_period_ends_at !== null
&& $invoice->invoice_date !== null
&& $invoice->invoice_date->gte($subscription->current_period_ends_at);
if ($isRenewal) {
$subscription->current_period_starts_at = $subscription->current_period_ends_at;
$subscription->current_period_ends_at = $subscription->current_period_ends_at->addYear();
} elseif (blank($subscription->current_period_starts_at)) {
$subscription->current_period_starts_at = today();
$subscription->current_period_ends_at = today()->addYear();
}
$subscription->status = SubscriptionStatus::Active;
$subscription->grace_ends_at = null;
$subscription->save();
$subscription->user?->notify(new SubscriptionActivatedNotification($subscription));
}
private function markPastDue(Subscription $subscription, SubscriptionInvoice $invoice): void
{
if ($subscription->status === SubscriptionStatus::PastDue) {
return;
}
$subscription->status = SubscriptionStatus::PastDue;
$subscription->grace_ends_at ??= ($invoice->due_date ?? today())->addDays(config('services.moneybird.grace_days'));
$subscription->save();
}
}