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 */ 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 */ 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 */ 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 $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 $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(); } }