390 lines
15 KiB
PHP
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();
|
|
}
|
|
}
|