diff --git a/.env.example b/.env.example index 17a8d22..ece3196 100644 --- a/.env.example +++ b/.env.example @@ -56,6 +56,13 @@ MAIL_PASSWORD=null MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" +MONEYBIRD_API_TOKEN="" +MONEYBIRD_ADMINISTRATION_ID= +MONEYBIRD_WEBHOOK_TOKEN= +MONEYBIRD_TAX_RATE_ID= +MONEYBIRD_LEDGER_ACCOUNT_ID= +MONEYBIRD_GRACE_DAYS=14 + AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 3c7c00c..66c3192 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -24,10 +24,14 @@ class CreateNewUser implements CreatesNewUsers 'password' => $this->passwordRules(), ])->validate(); - return User::create([ + $user = User::create([ 'name' => $input['name'], 'email' => $input['email'], 'password' => $input['password'], ]); + + $user->subscription()->create(); + + return $user; } } diff --git a/app/Console/Commands/MoneybirdSetup.php b/app/Console/Commands/MoneybirdSetup.php new file mode 100644 index 0000000..a58743f --- /dev/null +++ b/app/Console/Commands/MoneybirdSetup.php @@ -0,0 +1,124 @@ +error('Set MONEYBIRD_API_TOKEN in .env first.'); + + return self::FAILURE; + } + + $administrationId = config('services.moneybird.administration_id') ?: $this->discoverAdministration($client); + + if ($administrationId === null) { + return self::FAILURE; + } + + $this->line("Using administration_id: {$administrationId}"); + + // Every subsequent call reads administration_id straight from + // config, so make sure it's set for the rest of this run even if + // it was only just discovered above. + config(['services.moneybird.administration_id' => $administrationId]); + + $this->newLine(); + $this->listTaxRatesAndLedgerAccounts($client); + + $this->newLine(); + if ($this->confirm('Register a webhook now, pointing at '.config('app.url').'/webhooks/moneybird ?', false)) { + $this->registerWebhook($client); + } + + return self::SUCCESS; + } + + private function discoverAdministration(MoneybirdClient $client): ?string + { + try { + $administrations = $client->listAdministrations(); + } catch (MoneybirdException $e) { + $this->error($e->getMessage()); + + return null; + } + + if ($administrations === []) { + $this->error('No Moneybird administrations found for this token.'); + + return null; + } + + if (count($administrations) === 1) { + return (string) $administrations[0]['id']; + } + + $choices = collect($administrations)->mapWithKeys(fn ($admin) => [$admin['id'] => "{$admin['name']} ({$admin['id']})"]); + + $selected = $this->choice('Multiple administrations found — pick one', $choices->values()->all()); + + return (string) $choices->search($selected); + } + + private function listTaxRatesAndLedgerAccounts(MoneybirdClient $client): void + { + try { + $taxRates = $client->listTaxRates(); + $ledgerAccounts = $client->listLedgerAccounts(); + } catch (MoneybirdException $e) { + $this->error($e->getMessage()); + + return; + } + + $this->info('Tax rates — pick one for MONEYBIRD_TAX_RATE_ID (optional, invoices work without it):'); + $this->table(['id', 'name', 'percentage'], collect($taxRates)->map(fn ($rate) => [ + $rate['id'], $rate['name'] ?? '', $rate['percentage'] ?? '', + ])); + + $this->info('Ledger accounts — pick one for MONEYBIRD_LEDGER_ACCOUNT_ID (optional):'); + $this->table(['id', 'name'], collect($ledgerAccounts)->map(fn ($account) => [ + $account['id'], $account['name'] ?? '', + ])); + } + + private function registerWebhook(MoneybirdClient $client): void + { + try { + $webhook = $client->createWebhook([ + 'url' => config('app.url').'/webhooks/moneybird', + 'enabled_events' => [ + 'sales_invoice_created', + 'sales_invoice_created_based_on_recurring', + 'sales_invoice_state_changed_to_paid', + 'sales_invoice_state_changed_to_late', + 'sales_invoice_marked_as_uncollectible', + 'recurring_sales_invoice_invoice_created', + ], + ]); + } catch (MoneybirdException $e) { + $this->error($e->getMessage()); + + return; + } + + $this->info('Webhook registered. Add this to .env:'); + $this->line('MONEYBIRD_WEBHOOK_TOKEN="'.$webhook['token'].'"'); + } +} diff --git a/app/Console/Commands/SyncSubscriptions.php b/app/Console/Commands/SyncSubscriptions.php new file mode 100644 index 0000000..61a5a8d --- /dev/null +++ b/app/Console/Commands/SyncSubscriptions.php @@ -0,0 +1,22 @@ +syncSubscriptions(); + + $this->info("Marked {$result['pastDue']} subscription(s) past due and downgraded {$result['downgraded']} to Free."); + + return self::SUCCESS; + } +} diff --git a/app/Enums/SubscriptionPlan.php b/app/Enums/SubscriptionPlan.php new file mode 100644 index 0000000..e469810 --- /dev/null +++ b/app/Enums/SubscriptionPlan.php @@ -0,0 +1,86 @@ + __('Gratis'), + self::Starter => 'Certified Starter', + self::Unlimited => 'Certified Pro', + }; + } + + public function certificateLimit(): ?int + { + return match ($this) { + self::Free => 5, + self::Starter => 15, + self::Unlimited => null, + }; + } + + /** + * The net (excl. VAT) annual price — the amount invoiced via + * Moneybird, which adds VAT on top via the configured tax rate. + * Derived from priceInclVatCents() so the two can never drift apart. + */ + public function priceInCents(): int + { + return (int) round($this->priceInclVatCents() / (1 + self::VAT_RATE)); + } + + /** + * The advertised annual price, incl. 21% VAT — the headline figure + * shown on the pricing and billing pages. + */ + public function priceInclVatCents(): int + { + return match ($this) { + self::Free => 0, + self::Starter => 1900, + self::Unlimited => 4900, + }; + } + + /** + * The line-item description used on Moneybird invoices. + */ + public function moneybirdDescription(): string + { + return match ($this) { + self::Free => 'Certified — Gratis', + self::Starter => 'Certified Starter — jaarabonnement (1 jaar)', + self::Unlimited => 'Certified Pro — jaarabonnement (1 jaar)', + }; + } + + public function isPaid(): bool + { + return $this !== self::Free; + } + + /** + * Whether this plan includes document scanning (OCR). Free accounts + * enter certificate details manually; OCR is a paid-plan perk. + */ + public function includesOcr(): bool + { + return $this !== self::Free; + } +} diff --git a/app/Enums/SubscriptionStatus.php b/app/Enums/SubscriptionStatus.php new file mode 100644 index 0000000..62e0aef --- /dev/null +++ b/app/Enums/SubscriptionStatus.php @@ -0,0 +1,33 @@ + __('Active'), + self::AwaitingPayment => __('Awaiting payment'), + self::PastDue => __('Payment overdue'), + }; + } + + public function color(): string + { + return match ($this) { + self::Active => 'emerald', + self::AwaitingPayment => 'amber', + self::PastDue => 'red', + }; + } +} diff --git a/app/Http/Controllers/MoneybirdWebhookController.php b/app/Http/Controllers/MoneybirdWebhookController.php new file mode 100644 index 0000000..3efcd19 --- /dev/null +++ b/app/Http/Controllers/MoneybirdWebhookController.php @@ -0,0 +1,47 @@ +all()); + + $expected = (string) config('services.moneybird.webhook_token'); + $received = (string) $request->input('webhook_token', ''); + + if (blank($expected) || blank($received) || ! hash_equals($expected, $received)) { + abort(403); + } + + $entityType = $request->input('entity_type'); + $entityId = $request->input('entity_id'); + + if ($entityType === 'SalesInvoice' && filled($entityId)) { + $subscriptions->handleSalesInvoiceEvent((string) $entityId); + } + + return response()->noContent(); + } +} diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php new file mode 100644 index 0000000..c289d91 --- /dev/null +++ b/app/Models/Subscription.php @@ -0,0 +1,74 @@ + */ + use HasFactory; + + /** + * @var array + */ + protected $attributes = [ + 'plan' => 'free', + 'status' => 'active', + 'cancel_at_period_end' => false, + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'plan' => SubscriptionPlan::class, + 'status' => SubscriptionStatus::class, + 'current_period_starts_at' => 'date', + 'current_period_ends_at' => 'date', + 'grace_ends_at' => 'date', + 'cancel_at_period_end' => 'boolean', + ]; + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return HasMany + */ + public function invoices(): HasMany + { + return $this->hasMany(SubscriptionInvoice::class); + } +} diff --git a/app/Models/SubscriptionInvoice.php b/app/Models/SubscriptionInvoice.php new file mode 100644 index 0000000..1c4ba94 --- /dev/null +++ b/app/Models/SubscriptionInvoice.php @@ -0,0 +1,58 @@ + */ + use HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'plan' => SubscriptionPlan::class, + 'invoice_date' => 'date', + 'due_date' => 'date', + 'paid_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function subscription(): BelongsTo + { + return $this->belongsTo(Subscription::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 8eab6dd..8453b5b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,11 +3,13 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Enums\SubscriptionPlan; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Carbon; @@ -80,6 +82,35 @@ class User extends Authenticatable implements PasskeyUser return $this->hasMany(Category::class); } + /** + * @return HasOne + */ + public function subscription(): HasOne + { + return $this->hasOne(Subscription::class); + } + + /** + * The user's current plan. Falls back to Free for a user without a + * subscription row yet (e.g. factory-created users in tests) rather + * than requiring every call site to null-check. + */ + public function plan(): SubscriptionPlan + { + return $this->subscription?->plan ?? SubscriptionPlan::Free; + } + + /** + * Whether the user has already reached their plan's certificate limit, + * so a new upload should be blocked. Always false for unlimited plans. + */ + public function certificateLimitReached(): bool + { + $limit = $this->plan()->certificateLimit(); + + return $limit !== null && $this->certificates()->count() >= $limit; + } + /** * Get the user's initials */ diff --git a/app/Notifications/SubscriptionActivatedNotification.php b/app/Notifications/SubscriptionActivatedNotification.php new file mode 100644 index 0000000..becd5ed --- /dev/null +++ b/app/Notifications/SubscriptionActivatedNotification.php @@ -0,0 +1,38 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(User $notifiable): MailMessage + { + $message = (new MailMessage) + ->subject(__('Your Certified subscription is active')) + ->greeting(__('Hello :name,', ['name' => $notifiable->name])) + ->line(__('Thanks! Your payment was received and your :plan subscription is active.', ['plan' => $this->subscription->plan->label()])); + + if ($this->subscription->current_period_ends_at) { + $message->line(__('Your subscription runs until :date.', ['date' => $this->subscription->current_period_ends_at->toFormattedDateString()])); + } + + return $message->action(__('View your subscription'), route('billing.edit')); + } +} diff --git a/app/Notifications/SubscriptionDowngradedNotification.php b/app/Notifications/SubscriptionDowngradedNotification.php new file mode 100644 index 0000000..6355ae1 --- /dev/null +++ b/app/Notifications/SubscriptionDowngradedNotification.php @@ -0,0 +1,34 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(User $notifiable): MailMessage + { + return (new MailMessage) + ->subject(__('Your Certified subscription was switched back to Free')) + ->greeting(__('Hello :name,', ['name' => $notifiable->name])) + ->line(__("We didn't receive a (timely) payment, so your account has been switched back to the Free plan.")) + ->line(__("Your existing certificates are kept, but you won't be able to add new ones beyond the Free plan's limit.")) + ->action(__('Manage subscription'), route('billing.edit')); + } +} diff --git a/app/Notifications/SubscriptionPaymentDueNotification.php b/app/Notifications/SubscriptionPaymentDueNotification.php new file mode 100644 index 0000000..0580f30 --- /dev/null +++ b/app/Notifications/SubscriptionPaymentDueNotification.php @@ -0,0 +1,43 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(User $notifiable): MailMessage + { + $message = (new MailMessage) + ->subject(__('Invoice for your Certified subscription')) + ->greeting(__('Hello :name,', ['name' => $notifiable->name])) + ->line(__('A new invoice is ready for your :plan subscription.', ['plan' => $this->subscription->plan->label()])); + + if ($this->invoice->due_date) { + $message->line(__('Please pay it before :date to keep your subscription active.', ['date' => $this->invoice->due_date->toFormattedDateString()])); + } + + if ($this->invoice->payment_url) { + $message->action(__('Pay invoice'), $this->invoice->payment_url); + } + + return $message->line(__("If payment isn't received in time, we'll automatically switch your account back to the Free plan.")); + } +} diff --git a/app/Services/Billing/SubscriptionManager.php b/app/Services/Billing/SubscriptionManager.php new file mode 100644 index 0000000..5eafb1e --- /dev/null +++ b/app/Services/Billing/SubscriptionManager.php @@ -0,0 +1,390 @@ +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(); + } +} diff --git a/app/Services/Moneybird/MoneybirdClient.php b/app/Services/Moneybird/MoneybirdClient.php new file mode 100644 index 0000000..dcb0722 --- /dev/null +++ b/app/Services/Moneybird/MoneybirdClient.php @@ -0,0 +1,165 @@ +baseUrl(rtrim(config('services.moneybird.base_url'), '/').'/'.$administrationId) + ->acceptJson() + ->timeout(15); + } + + public function findContactByCustomerId(string $customerId): ?array + { + $response = $this->http()->get("contacts/customer_id/{$customerId}.json"); + + if ($response->status() === 404) { + return null; + } + + return $this->decode($response, "GET contacts/customer_id/{$customerId}"); + } + + /** + * @param array $attributes + */ + public function createContact(array $attributes): array + { + $response = $this->http()->post('contacts.json', ['contact' => $attributes]); + + return $this->decode($response, 'POST contacts'); + } + + /** + * @param array $attributes + */ + public function createSalesInvoice(array $attributes): array + { + $response = $this->http()->post('sales_invoices.json', ['sales_invoice' => $attributes]); + + return $this->decode($response, 'POST sales_invoices'); + } + + public function findSalesInvoice(string $id): array + { + $response = $this->http()->get("sales_invoices/{$id}.json"); + + return $this->decode($response, "GET sales_invoices/{$id}"); + } + + /** + * @param array $attributes + */ + public function createRecurringSalesInvoice(array $attributes): array + { + $response = $this->http()->post('recurring_sales_invoices.json', ['recurring_sales_invoice' => $attributes]); + + return $this->decode($response, 'POST recurring_sales_invoices'); + } + + /** + * @param array $attributes + */ + public function updateRecurringSalesInvoice(string $id, array $attributes): array + { + $response = $this->http()->patch("recurring_sales_invoices/{$id}.json", ['recurring_sales_invoice' => $attributes]); + + return $this->decode($response, "PATCH recurring_sales_invoices/{$id}"); + } + + public function deleteRecurringSalesInvoice(string $id): void + { + $response = $this->http()->delete("recurring_sales_invoices/{$id}.json"); + + // Already gone is fine — deleting is meant to be idempotent here. + if ($response->status() === 404) { + return; + } + + $this->decode($response, "DELETE recurring_sales_invoices/{$id}", allowEmpty: true); + } + + /** + * @return array> + */ + public function listAdministrations(): array + { + $response = Http::withToken(config('services.moneybird.api_token')) + ->acceptJson() + ->timeout(15) + ->get(rtrim(config('services.moneybird.base_url'), '/').'/administrations.json'); + + return $this->decode($response, 'GET administrations'); + } + + /** + * @return array> + */ + public function listTaxRates(): array + { + $response = $this->http()->get('tax_rates.json'); + + return $this->decode($response, 'GET tax_rates'); + } + + /** + * @return array> + */ + public function listLedgerAccounts(): array + { + $response = $this->http()->get('ledger_accounts.json'); + + return $this->decode($response, 'GET ledger_accounts'); + } + + /** + * @param array $attributes + */ + public function createWebhook(array $attributes): array + { + $response = $this->http()->post('webhooks.json', $attributes); + + return $this->decode($response, 'POST webhooks'); + } + + /** + * @return array + */ + private function decode(Response $response, string $context, bool $allowEmpty = false): array + { + if ($response->failed()) { + Log::error("Moneybird API request failed: {$context}", [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + throw new MoneybirdException("Moneybird API request failed ({$context}): HTTP {$response->status()}"); + } + + if ($allowEmpty && blank($response->body())) { + return []; + } + + return $response->json() ?? []; + } +} diff --git a/app/Services/Moneybird/MoneybirdException.php b/app/Services/Moneybird/MoneybirdException.php new file mode 100644 index 0000000..91a3861 --- /dev/null +++ b/app/Services/Moneybird/MoneybirdException.php @@ -0,0 +1,7 @@ +withMiddleware(function (Middleware $middleware): void { $middleware->web(append: [SetLocale::class]); + + // Trust the reverse proxy (e.g. BunkerWeb) so Laravel correctly + // detects HTTPS and the client IP from X-Forwarded-* headers. + // Set TRUSTED_PROXIES to "*" to trust the calling IP, or a + // comma-separated list of proxy IPs/CIDRs; defaults to none. + // + // NOTE: this runs before the config repository is bootstrapped, so + // it must use env() rather than config(). If APP_ENV is cached via + // `config:cache`, .env is no longer parsed at boot either — set + // TRUSTED_PROXIES as a real process env var (e.g. in the PHP-FPM + // pool's `env[]`) rather than only in .env. + $trustedProxies = trim((string) env('TRUSTED_PROXIES', '')); + + $middleware->trustProxies( + at: match (true) { + $trustedProxies === '' => null, + $trustedProxies === '*' => '*', + default => array_filter(array_map('trim', explode(',', $trustedProxies))), + }, + headers: Request::HEADER_X_FORWARDED_FOR + | Request::HEADER_X_FORWARDED_HOST + | Request::HEADER_X_FORWARDED_PORT + | Request::HEADER_X_FORWARDED_PROTO, + ); + + // Moneybird's webhook POSTs carry no CSRF token — it's verified via + // its own shared webhook_token instead (see MoneybirdWebhookController). + $middleware->validateCsrfTokens(except: [ + 'webhooks/moneybird', + ]); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( diff --git a/config/services.php b/config/services.php index 462c906..4b28edd 100644 --- a/config/services.php +++ b/config/services.php @@ -44,4 +44,19 @@ return [ 'secret_key' => env('TURNSTILE_SECRET_KEY'), ], + // Moneybird powers subscription billing (contacts, sales invoices, + // recurring invoice templates, and the webhook that reports payment). + // Run `php artisan moneybird:setup` after setting MONEYBIRD_API_TOKEN to + // discover administration_id/tax_rate_id/ledger_account_id and to + // register the webhook (which prints the webhook_token to store here). + 'moneybird' => [ + 'api_token' => env('MONEYBIRD_API_TOKEN'), + 'administration_id' => env('MONEYBIRD_ADMINISTRATION_ID'), + 'webhook_token' => env('MONEYBIRD_WEBHOOK_TOKEN'), + 'tax_rate_id' => env('MONEYBIRD_TAX_RATE_ID'), + 'ledger_account_id' => env('MONEYBIRD_LEDGER_ACCOUNT_ID'), + 'base_url' => env('MONEYBIRD_BASE_URL', 'https://moneybird.com/api/v2'), + 'grace_days' => (int) env('MONEYBIRD_GRACE_DAYS', 14), + ], + ]; diff --git a/database/factories/SubscriptionFactory.php b/database/factories/SubscriptionFactory.php new file mode 100644 index 0000000..8408696 --- /dev/null +++ b/database/factories/SubscriptionFactory.php @@ -0,0 +1,57 @@ + + */ +class SubscriptionFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'plan' => SubscriptionPlan::Free, + 'status' => SubscriptionStatus::Active, + ]; + } + + public function starter(): static + { + return $this->state(fn (array $attributes) => [ + 'plan' => SubscriptionPlan::Starter, + 'moneybird_contact_id' => 'contact-'.fake()->uuid(), + 'moneybird_recurring_invoice_id' => 'recurring-'.fake()->uuid(), + 'current_period_starts_at' => today(), + 'current_period_ends_at' => today()->addYear(), + ]); + } + + public function unlimited(): static + { + return $this->state(fn (array $attributes) => [ + 'plan' => SubscriptionPlan::Unlimited, + 'moneybird_contact_id' => 'contact-'.fake()->uuid(), + 'moneybird_recurring_invoice_id' => 'recurring-'.fake()->uuid(), + 'current_period_starts_at' => today(), + 'current_period_ends_at' => today()->addYear(), + ]); + } + + public function pastDue(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => SubscriptionStatus::PastDue, + 'grace_ends_at' => today()->subDay(), + ]); + } +} diff --git a/database/factories/SubscriptionInvoiceFactory.php b/database/factories/SubscriptionInvoiceFactory.php new file mode 100644 index 0000000..7304bac --- /dev/null +++ b/database/factories/SubscriptionInvoiceFactory.php @@ -0,0 +1,39 @@ + + */ +class SubscriptionInvoiceFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'subscription_id' => Subscription::factory(), + 'moneybird_invoice_id' => (string) fake()->unique()->randomNumber(9), + 'plan' => SubscriptionPlan::Starter, + 'amount_cents' => SubscriptionPlan::Starter->priceInCents(), + 'state' => 'open', + 'invoice_date' => today(), + 'due_date' => today()->addDays(14), + 'payment_url' => 'https://moneybird.dev/pay/'.fake()->uuid(), + ]; + } + + public function paid(): static + { + return $this->state(fn (array $attributes) => [ + 'state' => 'paid', + 'paid_at' => now(), + ]); + } +} diff --git a/database/migrations/2026_08_09_000001_create_subscriptions_table.php b/database/migrations/2026_08_09_000001_create_subscriptions_table.php new file mode 100644 index 0000000..12de781 --- /dev/null +++ b/database/migrations/2026_08_09_000001_create_subscriptions_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->string('plan')->default('free'); + $table->string('status')->default('active'); + $table->string('moneybird_contact_id')->nullable(); + $table->string('moneybird_recurring_invoice_id')->nullable(); + $table->date('current_period_starts_at')->nullable(); + $table->date('current_period_ends_at')->nullable(); + $table->date('grace_ends_at')->nullable(); + $table->boolean('cancel_at_period_end')->default(false); + $table->timestamps(); + }); + + // Backfill a Free subscription for any user that already existed + // before this table did, so every user always has a row. + DB::table('users')->pluck('id')->each(function (int $userId) { + DB::table('subscriptions')->insert([ + 'user_id' => $userId, + 'plan' => 'free', + 'status' => 'active', + 'created_at' => now(), + 'updated_at' => now(), + ]); + }); + } + + public function down(): void + { + Schema::dropIfExists('subscriptions'); + } +}; diff --git a/database/migrations/2026_08_09_000002_create_subscription_invoices_table.php b/database/migrations/2026_08_09_000002_create_subscription_invoices_table.php new file mode 100644 index 0000000..f8ef82b --- /dev/null +++ b/database/migrations/2026_08_09_000002_create_subscription_invoices_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('subscription_id')->constrained()->cascadeOnDelete(); + $table->string('moneybird_invoice_id')->unique(); + $table->string('plan'); + $table->unsignedInteger('amount_cents'); + $table->string('state'); + $table->date('invoice_date')->nullable(); + $table->date('due_date')->nullable(); + $table->dateTime('paid_at')->nullable(); + $table->string('payment_url')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('subscription_invoices'); + } +}; diff --git a/lang/en.json b/lang/en.json index 665d3e7..66b07be 100644 --- a/lang/en.json +++ b/lang/en.json @@ -32,7 +32,7 @@ "Al je data staat in Europese datacenters, onder Europees recht. Geen Amerikaanse cloud, geen CLOUD Act, volledig AVG/GDPR-compliant.": "All your data is stored in European data centers, under European law. No American cloud, no CLOUD Act, fully GDPR compliant.", "Algemene voorwaarden": "Terms of Service", "Alle applicatie- en documentdata staat in Europese datacenters. We gebruiken bewust geen Amerikaanse hyperscalers: je data valt daardoor niet onder de Amerikaanse CLOUD Act en blijft volledig binnen de reikwijdte van de AVG/GDPR. Back-ups worden versleuteld opgeslagen, eveneens binnen de EU.": "All application and document data is stored in European data centers. We deliberately avoid American hyperscalers: your data therefore does not fall under the American CLOUD Act and remains fully within the scope of the GDPR. Backups are stored encrypted, also within the EU.", - "Alle prijzen zijn per jaar en exclusief btw. Start gratis — upgraden kan altijd, opzeggen ook.": "All prices are per year and exclude VAT. Start for free — you can upgrade or cancel at any time.", + "Alle prijzen zijn per jaar en inclusief 21% btw. Start gratis — upgraden kan altijd, opzeggen ook.": "All prices are per year and include 21% VAT. Start for free — you can upgrade or cancel at any time.", "Alle rechten voorbehouden.": "All rights reserved.", "Alleen jij, vanuit je eigen account. De scheiding tussen accounts wordt op databaseniveau afgedwongen. Onze eigen medewerkers hebben geen toegang tot documentinhoud, behalve wanneer jij daar bij een supportverzoek expliciet toestemming voor geeft.": "Only you, from your own account. The separation between accounts is enforced at the database level. Our own staff have no access to document content, except when you explicitly grant permission for a support request.", "Alles over facturering, opzeggen en overstappen.": "Everything about billing, cancelling, and switching plans.", @@ -62,7 +62,6 @@ "Artikel 8 — Gegevens, privacy en OCR-verwerking": "Article 8 — Data, privacy, and OCR processing", "Artikel 9 — Aansprakelijkheid": "Article 9 — Liability", "Artikel 9 — Meldplicht datalekken": "Article 9 — Data breach notification", - "Automatische OCR": "Automatic OCR", "Automatische OCR-scan": "Automatic OCR scan", "Automatische tekstherkenning (OCR) vindt alleen plaats als jij dit zelf inschakelt, via een door jou gekozen Europese provider. Je kunt OCR ook uitschakelen en velden handmatig invullen.": "Automatic text recognition (OCR) only takes place if you enable it yourself, via a European provider of your choice. You can also disable OCR and fill in fields manually.", "Bckstage exploiteert Certified, een persoonlijke certificatenmanager waarmee je zelf je certificaten, diploma's en documenten zoals een VOG bijhoudt. Deze verklaring legt uit welke persoonsgegevens we verzamelen, waarom, hoe lang we ze bewaren en wat je rechten zijn onder de AVG/GDPR.": "Bckstage operates Certified, a personal certificate manager that lets you keep track of your own certificates, diplomas, and documents such as a Certificate of Conduct (VOG). This policy explains what personal data we collect, why, how long we keep it, and what rights you have under the GDPR.", @@ -70,7 +69,7 @@ "Bckstage, die persoonsgegevens verwerkt namens de verwerkingsverantwoordelijke.": "Bckstage, which processes personal data on behalf of the controller.", "Bekijk de prijzen": "View pricing", "Bekijk de verwerkersovereenkomst": "View the Data Processing Agreement", - "Betaalde pakketten worden één keer per jaar gefactureerd: €12,50 per jaar voor Starter, €45 per jaar voor Onbeperkt, beide exclusief btw. Je ontvangt een factuur die je direct in je administratie kunt verwerken.": "Paid plans are billed once a year: €12.50 per year for Starter, €45 per year for Unlimited, both excluding VAT. You receive an invoice you can process directly in your own bookkeeping.", + "Betaalde pakketten worden één keer per jaar gefactureerd: €19 per jaar voor Certified Starter, €49 per jaar voor Certified Pro, beide inclusief 21% btw. Je ontvangt een factuur die je direct in je administratie kunt verwerken.": "Paid plans are billed once a year: €19 per year for Certified Starter, €49 per year for Certified Pro, both including 21% VAT. You receive an invoice you can process directly in your own bookkeeping.", "Beveiliging": "Security", "Beveiligingslogboeken": "Security logs", "Bezorging van reminders en accountmeldingen": "Delivery of reminders and account notifications", @@ -93,7 +92,7 @@ "Certified is een hulpmiddel om je certificaten te overzien en je te herinneren aan vervaldatums — geen garantie dat je nooit een deadline mist. De Dienst wordt geleverd \"zoals het is\".": "Certified is a tool to help you keep an overview of your certificates and remind you of expiry dates — not a guarantee that you'll never miss a deadline. The Service is provided \"as is\".", "Certified is gebouwd voor persoonlijk gebruik: jij beheert je eigen VOG, diploma's en certificaten. In dat geval ben je zelf geen verwerkingsverantwoordelijke in de zin van de AVG en heb je deze overeenkomst niet nodig.": "Certified is built for personal use: you manage your own Certificate of Conduct (VOG), diplomas, and certificates. In that case, you are not a controller within the meaning of the GDPR and you don't need this agreement.", "Certified is niet bedoeld voor gebruik door personen jonger dan 16 jaar. Vermoed je dat we onbedoeld gegevens van een minderjarige hebben verzameld, neem dan contact met ons op zodat we deze kunnen verwijderen.": "Certified is not intended for use by people under the age of 16. If you suspect we have unintentionally collected data from a minor, please contact us so we can delete it.", - "Certified kent een gratis pakket en de betaalde abonnementen Starter en Onbeperkt, zoals beschreven op onze prijzenpagina. Betaalde abonnementen worden jaarlijks vooraf gefactureerd, exclusief btw.": "Certified has a free plan and the paid subscriptions Starter and Unlimited, as described on our pricing page. Paid subscriptions are billed annually in advance, excluding VAT.", + "Certified kent een gratis pakket en de betaalde abonnementen Certified Starter en Certified Pro, zoals beschreven op onze prijzenpagina. Betaalde abonnementen worden jaarlijks vooraf gefactureerd, inclusief 21% btw.": "Certified has a free plan and the paid subscriptions Certified Starter and Certified Pro, as described on our pricing page. Paid subscriptions are billed annually in advance, including 21% VAT.", "Certified scant je VOG, diploma's en certificaten automatisch in, bewaakt elke vervaldatum en deelt ze alleen versleuteld — zonder Excel-lijstjes, losse mapjes of kwetsbare e-mailbijlagen.": "Certified automatically scans your Certificate of Conduct (VOG), diplomas, and certificates, watches every expiry date, and only shares them encrypted — no spreadsheets, loose folders, or vulnerable email attachments.", "Certified — home": "Certified — home", "Contact": "Contact", @@ -136,7 +135,7 @@ "EHBO & BHV": "First aid & emergency response", "EU (Nederland)": "EU (Netherlands)", "Een volledig overzicht van onze beveiligingsmaatregelen vind je op onze security-pagina.": "A full overview of our security measures can be found on our security page.", - "Eerlijke prijzen voor je eigen certificaatbeheer. Start gratis met maximaal 5 certificaten — inclusief OCR, slimme reminders en AVG-proof delen.": "Fair pricing for managing your own certificates. Start for free with up to 5 certificates — including OCR, smart reminders, and GDPR-proof sharing.", + "Eerlijke prijzen voor je eigen certificaatbeheer. Start gratis met maximaal 5 certificaten — inclusief slimme reminders en AVG-proof delen.": "Fair pricing for managing your own certificates. Start for free with up to 5 certificates — including smart reminders and GDPR-proof sharing.", "Eerlijke prijzen, geen verrassingen": "Fair pricing, no surprises", "Eigen infrastructuur": "Own infrastructure", "Elk gebruik dat in strijd is met toepasselijk recht of rechten van derden": "Any use that violates applicable law or the rights of third parties", @@ -184,7 +183,7 @@ "Is Certified geschikt voor gevoelige documenten, zoals een VOG?": "Is Certified suitable for sensitive documents, such as a Certificate of Conduct?", "Is het Gratis-pakket echt gratis?": "Is the Free plan really free?", "Ja, daar is Certified juist voor gebouwd. Je documenten staan versleuteld op Europese servers, worden nooit als open bijlage gedeeld en zijn alleen toegankelijk met jouw account. Delen met anderen gebeurt via een tijdelijke ondertekende link of een AES-256 versleutelde ZIP.": "Yes, that's exactly what Certified is built for. Your documents are stored encrypted on European servers, are never shared as an open attachment, and are only accessible with your account. Sharing with others happens via a temporary signed link or an AES-256 encrypted ZIP.", - "Ja, voor altijd. Je beheert er tot 5 certificaten mee, inclusief OCR-scan, reminders en veilig delen. Geen creditcard, geen verborgen kosten — pas als je meer dan 5 certificaten wilt bewaken, stap je over op een betaald pakket.": "Yes, forever. You can manage up to 5 certificates with it, including OCR scanning, reminders, and secure sharing. No credit card, no hidden costs — you only switch to a paid plan once you want to track more than 5 certificates.", + "Ja, voor altijd. Je beheert er tot 5 certificaten mee, inclusief reminders en veilig delen. Geen creditcard, geen verborgen kosten — pas als je meer dan 5 certificaten wilt bewaken, stap je over op een betaald pakket.": "Yes, forever. You can manage up to 5 certificates with it, including reminders and secure sharing. No credit card, no hidden costs — you only switch to a paid plan once you want to track more than 5 certificates.", "Ja. Je kunt op elk moment al je certificaten en metadata exporteren. Zeg je op, dan verwijderen we je data — inclusief back-ups — binnen 30 dagen definitief, conform de AVG.": "Yes. You can export all your certificates and metadata at any time. If you cancel, we permanently delete your data — including backups — within 30 days, in accordance with the GDPR.", "Je BHV-diploma verloopt over 2 maanden": "Your emergency response certificate expires in 2 months", "Je VOG of diploma als onbeveiligde bijlage naar een nieuwe werkgever of instantie mailen is vragen om problemen. Certified deelt je documenten alleen versleuteld en tijdelijk.": "Emailing your Certificate of Conduct or diploma as an unsecured attachment to a new employer or authority is asking for trouble. Certified only shares your documents encrypted and temporarily.", @@ -194,7 +193,7 @@ "Je certificaten en accountgegevens worden opgeslagen in datacenters binnen de Europese Unie. We gebruiken geen Amerikaanse cloudproviders voor hosting, waardoor je gegevens niet onder de Amerikaanse CLOUD Act vallen.": "Your certificates and account data are stored in data centers within the European Union. We do not use American cloud providers for hosting, so your data does not fall under the American CLOUD Act.", "Je documenten worden versleuteld opgeslagen en al het verkeer loopt via TLS. Deel je een certificaat met iemand anders, dan gebeurt dat via een AES-256 versleutelde ZIP met een apart verstuurd wachtwoord, of via een ondertekende link die na 48 uur automatisch onbruikbaar wordt. API-sleutels van OCR-providers slaan we versleuteld op.": "Your documents are stored encrypted and all traffic runs over TLS. If you share a certificate with someone else, this happens via an AES-256 encrypted ZIP with a separately sent password, or via a signed link that automatically becomes unusable after 48 hours. We store OCR provider API keys encrypted.", "Je kunt je account op elk moment opzeggen vanuit je instellingen. Je kunt daarvoor al je certificaten en gegevens exporteren. Na opzegging verwijderen we je gegevens, inclusief back-ups, definitief binnen 30 dagen. Wij kunnen je account opschorten of beëindigen bij een wezenlijke schending van artikel 4.": "You can cancel your account at any time from your settings. Before doing so, you can export all your certificates and data. After cancellation, we permanently delete your data, including backups, within 30 days. We may suspend or terminate your account for a material breach of article 4.", - "Je uploadt een PDF of foto van je certificaat. Onze OCR-engine herkent automatisch de titel, uitgever en vervaldatum en vult die voor je in. Jij controleert en slaat op — dat is alles. Je kiest zelf welke Europese provider (Klippa of Mistral AI) de verwerking doet.": "You upload a PDF or photo of your certificate. Our OCR engine automatically recognizes the title, issuer, and expiry date and fills them in for you. You check and save — that's it. You choose which European provider (Klippa or Mistral AI) does the processing.", + "Je uploadt een PDF of foto van je certificaat. Onze OCR-engine herkent automatisch de titel, uitgever en vervaldatum en vult die voor je in. Jij controleert en slaat op — dat is alles. Je kiest zelf welke Europese provider (Klippa of Mistral AI) de verwerking doet. OCR is beschikbaar bij Certified Starter en Certified Pro; bij het Gratis-pakket vul je de gegevens handmatig in.": "You upload a PDF or photo of your certificate. Our OCR engine automatically recognizes the title, issuer, and expiry date and fills them in for you. You check and save — that's it. You choose which European provider (Klippa or Mistral AI) does the processing. OCR is available on Certified Starter and Certified Pro; on the Free plan you fill in the details manually.", "Je verklaart dat je ten minste 16 jaar oud bent.": "You declare that you are at least 16 years old.", "Jij": "You", "Jij behoudt alle rechten op je eigen Inhoud. Je geeft ons een beperkte licentie om je Inhoud op te slaan, te verwerken en te verzenden, uitsluitend voor zover nodig om de Dienst aan jou te leveren.": "You retain all rights to your own Content. You grant us a limited license to store, process, and transmit your Content, solely to the extent necessary to provide the Service to you.", @@ -213,9 +212,8 @@ "Locatie": "Location", "Login": "Login", "Malware, virussen of schadelijke code uploaden of verspreiden via de Dienst": "Uploading or spreading malware, viruses, or harmful code via the Service", - "Maximaal 20 certificaten": "Up to 20 certificates", + "Maximaal :count certificaten": "Up to :count certificates", "Maximaal 48 uur. Daarna is de ondertekende URL cryptografisch ongeldig en kan niemand het document meer openen — ook niet met de oude link. Versleutelde ZIP-exports blijven beveiligd met AES-256 en het apart verstuurde wachtwoord.": "A maximum of 48 hours. After that, the signed URL is cryptographically invalid and no one can open the document anymore — not even with the old link. Encrypted ZIP exports remain protected with AES-256 and the separately sent password.", - "Maximaal 5 certificaten": "Up to 5 certificates", "Meest gekozen": "Most popular", "Meestal niet — gebruik je Certified voor jezelf, dan ben je geen verwerkingsverantwoordelijke. Wil je er als zzp'er of eenmanszaak toch één voor je administratie, dan staat de standaard verwerkersovereenkomst gratis voor je klaar op de DPA-pagina.": "Usually not — if you use Certified for yourself, you are not a controller. If you're a freelancer or sole proprietor and want one for your own records anyway, the standard Data Processing Agreement is available for free on our DPA page.", "Menu openen": "Open menu", @@ -228,7 +226,7 @@ "Nederland (EU)": "Netherlands (EU)", "Nederlands recht, EU-hosting": "Dutch law, EU hosting", "Nee. Certified draait uitsluitend op Europese infrastructuur van Europese leveranciers. Er is geen Amerikaanse partij in de hostingketen, dus de CLOUD Act is niet van toepassing op jouw data.": "No. Certified runs exclusively on European infrastructure from European providers. There is no American party in the hosting chain, so the CLOUD Act does not apply to your data.", - "Nee. OCR-scans zitten bij elk pakket inbegrepen — ook bij Gratis. Je betaalt nooit per scan.": "No. OCR scans are included in every plan — even the Free one. You never pay per scan.", + "Nee, je betaalt nooit per scan. OCR zit bij Certified Starter en Certified Pro inbegrepen; bij het Gratis-pakket vul je certificaten handmatig in.": "No, you never pay per scan. OCR is included with Certified Starter and Certified Pro; on the Free plan you fill in certificates manually.", "Neem contact met ons op via": "Contact us at", "Niets ergs — je data blijft gewoon staan. We sturen een melding en je kunt eenvoudig upgraden naar het volgende pakket. We verwijderen nooit certificaten omdat je over een limiet heen bent.": "Nothing bad — your data simply stays put. We send a notification and you can easily upgrade to the next plan. We never delete certificates because you've gone over a limit.", "Niets in deze Overeenkomst beperkt aansprakelijkheid voor overlijden of persoonlijk letsel door onze nalatigheid, fraude, of andere aansprakelijkheid die niet kan worden uitgesloten onder Nederlands recht.": "Nothing in this Agreement limits liability for death or personal injury caused by our negligence, fraud, or any other liability that cannot be excluded under Dutch law.", @@ -239,7 +237,6 @@ "OCR-herkenning — alleen indien geactiveerd": "OCR recognition — only if activated", "OCR-verwerking": "OCR processing", "OCR-verwerking (optioneel)": "OCR processing (optional)", - "Onbeperkt": "Unlimited", "Onbeperkt certificaten": "Unlimited certificates", "Onbeveiligd verstuurd — een onnodig risico op misbruik van je gegevens": "Sent unsecured — an unnecessary risk of misuse of your data", "Onder de AVG heb je recht op inzage, rectificatie, verwijdering, beperking van de verwerking, gegevensoverdraagbaarheid en bezwaar. Je kunt je certificaten en gegevens grotendeels zelf inzien en exporteren vanuit je account; voor andere verzoeken mail je ons. We reageren binnen één maand. Je hebt ook het recht een klacht in te dienen bij de Autoriteit Persoonsgegevens.": "Under the GDPR you have the right to access, rectification, erasure, restriction of processing, data portability, and objection. You can view and export most of your certificates and data yourself from your account; for other requests, email us. We respond within one month. You also have the right to lodge a complaint with the Dutch Data Protection Authority.", @@ -287,9 +284,8 @@ "Staat je vraag er niet tussen? Mail ons via support@certified.eu.": "Don't see your question? Email us at support@certified.eu.", "Standaard ontvang je twee maanden van tevoren een e-mail, en daarna opnieuw zolang je het certificaat niet hebt verlengd. Verloopt een certificaat vandaag, dan krijg je direct een urgente melding. Het aantal maanden stel je zelf in bij je instellingen.": "By default you receive an email two months in advance, and again as long as you haven't renewed the certificate. If a certificate expires today, you get an urgent notification immediately. You set the number of months yourself in your settings.", "Start gratis": "Start for free", - "Start gratis en groei mee wanneer je meer certificaten beheert. Elk pakket bevat OCR, reminders en veilig delen.": "Start for free and grow along as you manage more certificates. Every plan includes OCR, reminders, and secure sharing.", - "Start met Onbeperkt": "Start with Unlimited", - "Start met Starter": "Start with Starter", + "Start gratis en groei mee wanneer je meer certificaten beheert. Elk pakket bevat reminders en veilig delen; OCR zit bij Certified Starter en Certified Pro.": "Start for free and grow along as you manage more certificates. Every plan includes reminders and secure sharing; OCR comes with Certified Starter and Certified Pro.", + "Start met :plan": "Start with :plan", "Status": "Status", "Stop met certificaten mailen als losse bijlage": "Stop emailing certificates as loose attachments", "Strikte scheiding tussen accounts": "Strict separation between accounts", @@ -377,7 +373,7 @@ "Wij behouden alle intellectuele eigendomsrechten op de Dienst zelf, waaronder de broncode, het ontwerp en onze merken.": "We retain all intellectual property rights to the Service itself, including the source code, design, and our trademarks.", "Wijzigingen": "Changes", "Wissel tussen licht en donker thema": "Switch between light and dark theme", - "Zeker. Het Gratis-pakket is voor altijd gratis en laat je tot 5 certificaten beheren — zonder creditcard. Groei je eruit, dan upgrade je eenvoudig naar Starter (20 certificaten) of Onbeperkt.": "Sure. The Free plan is free forever and lets you manage up to 5 certificates — no credit card. If you outgrow it, you can easily upgrade to Starter (20 certificates) or Unlimited.", + "Zeker. Het Gratis-pakket is voor altijd gratis en laat je tot 5 certificaten beheren — zonder creditcard. Groei je eruit, dan upgrade je eenvoudig naar Certified Starter (15 certificaten) of Certified Pro.": "Sure. The Free plan is free forever and lets you manage up to 5 certificates — no credit card. If you outgrow it, you can easily upgrade to Certified Starter (15 certificates) or Certified Pro.", "Zijn er kosten per OCR-scan?": "Are there costs per OCR scan?", "Zo mis je nooit de deadline om het op tijd te verlengen.": "That way you never miss the deadline to renew it in time.", "Zolang je deze Overeenkomst naleeft, verleent de Aanbieder je een beperkt, niet-overdraagbaar recht om de Dienst te gebruiken.": "As long as you comply with this Agreement, the Provider grants you a limited, non-transferable right to use the Service.", @@ -387,6 +383,7 @@ "de verwerker schakelt geen nieuwe sub-verwerkers in zonder de verwerkingsverantwoordelijke hierover ten minste 14 dagen vooraf te informeren.": "the processor does not engage new sub-processors without informing the controller at least 14 days in advance.", "een beveiligingsincident zoals gedefinieerd in artikel 4 lid 12 AVG.": "a security incident as defined in article 4(12) GDPR.", "een derde partij die door de verwerker wordt ingeschakeld om specifieke verwerkingsactiviteiten uit te voeren.": "a third party engaged by the processor to carry out specific processing activities.", + "excl. btw": "excl. VAT", "jaar": "year", "niet als bijzaak": "not an afterthought", "oude manier": "old way", diff --git a/lang/nl.json b/lang/nl.json index 7ddc9b3..14595b3 100644 --- a/lang/nl.json +++ b/lang/nl.json @@ -9,6 +9,7 @@ ":count time|:count times": ":count keer|:count keer", ":name has shared :count certificate with you.|:name has shared :count certificates with you.": ":name heeft :count certificaat met je gedeeld.|:name heeft :count certificaten met je gedeeld.", ":name shared certificates with you": ":name heeft certificaten met je gedeeld", + ":used of :limit certificates used": ":used van :limit certificaten gebruikt", "A new verification link has been sent to the email address you provided during registration.": "Er is een nieuwe verificatielink verstuurd naar het e-mailadres dat je bij registratie hebt opgegeven.", "A new verification link has been sent to your email address.": "Er is een nieuwe verificatielink naar je e-mailadres verstuurd.", "A password is generated for you; share it with the recipient through a different channel (SMS or WhatsApp).": "Er wordt een wachtwoord voor je gegenereerd; deel dit met de ontvanger via een ander kanaal (sms of WhatsApp).", @@ -44,6 +45,7 @@ "Certificate details": "Certificaatgegevens", "Certificate expires today: :title": "Certificaat verloopt vandaag: :title", "Certificate expiring soon: :title": "Certificaat verloopt binnenkort: :title", + "Certificate limit reached": "Certificaatlimiet bereikt", "Certificate number": "Certificaatnummer", "Certificate settings updated.": "Certificaatinstellingen bijgewerkt.", "Certificate updated.": "Certificaat bijgewerkt.", @@ -75,6 +77,7 @@ "Disable 2FA": "2FA uitschakelen", "Document preview": "Documentvoorbeeld", "Document scanning (OCR)": "Documentscannen (OCR)", + "Document scanning is available on Certified Starter and Certified Pro.": "Documentscannen is beschikbaar bij Certified Starter en Certified Pro.", "Documentation": "Documentatie", "Don't have an account?": "Nog geen account?", "Done": "Klaar", @@ -104,6 +107,7 @@ "Enter your details below to create your account": "Vul hieronder je gegevens in om je account aan te maken", "Enter your email and password below to log in": "Vul hieronder je e-mailadres en wachtwoord in om in te loggen", "Enter your email to receive a password reset link": "Vul je e-mailadres in om een link te ontvangen om je wachtwoord opnieuw in te stellen", + "excl. VAT": "excl. btw", "Expired": "Verlopen", "Expires": "Vervalt", "Expiring soon": "Vervalt binnenkort", @@ -256,6 +260,8 @@ "Update the appearance settings for your account": "Werk de weergave-instellingen van je account bij", "Update the details or replace the stored document.": "Werk de gegevens bij of vervang het opgeslagen document.", "Update your name and email address": "Werk je naam en e-mailadres bij", + "Upgrade your plan": "Upgrade je pakket", + "Upgrade your plan to add more certificates.": "Upgrade je pakket om meer certificaten toe te voegen.", "Upload a document to preview it, then confirm the details — pre-filled automatically if scanning is enabled.": "Upload een document om het te bekijken en bevestig daarna de gegevens — automatisch ingevuld als scannen is ingeschakeld.", "Upload your first certificate to start tracking issue dates, expiry, and reminders.": "Upload je eerste certificaat om uitgiftedatums, vervaldatums en herinneringen bij te houden.", "Uploading…": "Uploaden…", @@ -264,6 +270,7 @@ "Verify a certificate": "Certificaat verifiëren", "Verify authentication code": "Authenticatiecode verifiëren", "View all": "Alles bekijken", + "View plans": "Bekijk pakketten", "View recovery codes": "Herstelcodes bekijken", "View shared certificates": "Bekijk gedeelde certificaten", "View your certificates": "Bekijk je certificaten", @@ -274,5 +281,45 @@ "You will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.": "Tijdens het inloggen wordt om een beveiligde, willekeurige pincode gevraagd, die je kunt ophalen uit de TOTP-ondersteunende app op je telefoon.", "Your certificate \":title\" expires today.": "Je certificaat \":title\" verloopt vandaag.", "Your certificate \":title\" will expire on :date.": "Je certificaat \":title\" verloopt op :date.", - "Your email address is unverified.": "Je e-mailadres is niet geverifieerd." + "Your email address is unverified.": "Je e-mailadres is niet geverifieerd.", + "Your plan is full — upgrade to add more certificates.": "Je pakket zit vol — upgrade om meer certificaten toe te voegen.", + "Awaiting payment": "Wacht op betaling", + "Payment overdue": "Betaling verlopen", + "Billing": "Facturering", + "Manage your Certified subscription and invoices": "Beheer je Certified-abonnement en facturen", + "Ends on :date": "Stopt op :date", + "Undo cancellation": "Opzegging ongedaan maken", + "Renews on :date": "Verlengt op :date", + "Are you sure you want to cancel? Your subscription stays active until the end of the current period.": "Weet je zeker dat je wilt opzeggen? Je abonnement blijft actief tot het einde van de huidige periode.", + "Cancel subscription": "Abonnement opzeggen", + "We haven't received payment yet. Without payment before :date we'll automatically switch you back to the Free plan.": "We hebben nog geen betaling ontvangen. Zonder betaling voor :date schakelen we je automatisch terug naar het gratis plan.", + "Change plan": "Plan wijzigen", + "Unlimited certificates": "Onbeperkt certificaten", + "Up to :count certificates": "Maximaal :count certificaten", + "Current plan": "Huidig plan", + "Upgrade": "Upgraden", + "Switch": "Overstappen", + "Invoices": "Facturen", + "Date": "Datum", + "Amount": "Bedrag", + "Status": "Status", + "Pay": "Betalen", + "Plan": "Plan", + "Plan updated — complete the payment to keep your new limit.": "Plan bijgewerkt — rond de betaling af om je nieuwe limiet te behouden.", + "Plan updated.": "Plan bijgewerkt.", + "Your subscription won't renew. You'll keep access until the end of the current period.": "Je abonnement wordt niet verlengd. Je houdt toegang tot het einde van de huidige periode.", + "Cancellation undone — your subscription will renew again.": "Opzegging ongedaan gemaakt — je abonnement wordt weer verlengd.", + "Invoice for your Certified subscription": "Factuur voor je Certified-abonnement", + "A new invoice is ready for your :plan subscription.": "Er staat een nieuwe factuur klaar voor je :plan-abonnement.", + "Please pay it before :date to keep your subscription active.": "Betaal deze voor :date om je abonnement actief te houden.", + "Pay invoice": "Factuur betalen", + "If payment isn't received in time, we'll automatically switch your account back to the Free plan.": "Ontvangen we niet op tijd een betaling, dan schakelen we je account automatisch terug naar het gratis plan.", + "Your Certified subscription is active": "Je Certified-abonnement is actief", + "Thanks! Your payment was received and your :plan subscription is active.": "Bedankt! Je betaling is ontvangen en je :plan-abonnement is actief.", + "Your subscription runs until :date.": "Je abonnement loopt tot :date.", + "View your subscription": "Bekijk je abonnement", + "Your Certified subscription was switched back to Free": "Je Certified-abonnement is teruggezet naar Gratis", + "We didn't receive a (timely) payment, so your account has been switched back to the Free plan.": "We hebben geen (tijdige) betaling ontvangen, dus je account is teruggezet naar het gratis plan.", + "Your existing certificates are kept, but you won't be able to add new ones beyond the Free plan's limit.": "Je bestaande certificaten blijven bewaard, maar je kunt er geen nieuwe meer toevoegen boven de limiet van het gratis plan.", + "Manage subscription": "Abonnement beheren" } diff --git a/resources/views/marketing/home.blade.php b/resources/views/marketing/home.blade.php index e546bdd..02bf618 100644 --- a/resources/views/marketing/home.blade.php +++ b/resources/views/marketing/home.blade.php @@ -494,7 +494,7 @@ {{ __('Eerlijke prijzen, geen verrassingen') }}

- {{ __('Start gratis en groei mee wanneer je meer certificaten beheert. Elk pakket bevat OCR, reminders en veilig delen.') }} + {{ __('Start gratis en groei mee wanneer je meer certificaten beheert. Elk pakket bevat reminders en veilig delen; OCR zit bij Certified Starter en Certified Pro.') }}

@@ -505,10 +505,10 @@ {{-- ============ FAQ ============ --}} @include('marketing.partials.faq', ['faqs' => [ ['q' => __('Is Certified geschikt voor gevoelige documenten, zoals een VOG?'), 'a' => __('Ja, daar is Certified juist voor gebouwd. Je documenten staan versleuteld op Europese servers, worden nooit als open bijlage gedeeld en zijn alleen toegankelijk met jouw account. Delen met anderen gebeurt via een tijdelijke ondertekende link of een AES-256 versleutelde ZIP.')], - ['q' => __('Hoe werkt de automatische OCR-scan?'), 'a' => __('Je uploadt een PDF of foto van je certificaat. Onze OCR-engine herkent automatisch de titel, uitgever en vervaldatum en vult die voor je in. Jij controleert en slaat op — dat is alles. Je kiest zelf welke Europese provider (Klippa of Mistral AI) de verwerking doet.')], + ['q' => __('Hoe werkt de automatische OCR-scan?'), 'a' => __('Je uploadt een PDF of foto van je certificaat. Onze OCR-engine herkent automatisch de titel, uitgever en vervaldatum en vult die voor je in. Jij controleert en slaat op — dat is alles. Je kiest zelf welke Europese provider (Klippa of Mistral AI) de verwerking doet. OCR is beschikbaar bij Certified Starter en Certified Pro; bij het Gratis-pakket vul je de gegevens handmatig in.')], ['q' => __('Wanneer krijg ik een reminder als een certificaat bijna verloopt?'), 'a' => __('Standaard ontvang je twee maanden van tevoren een e-mail, en daarna opnieuw zolang je het certificaat niet hebt verlengd. Verloopt een certificaat vandaag, dan krijg je direct een urgente melding. Het aantal maanden stel je zelf in bij je instellingen.')], ['q' => __('Waar wordt mijn data opgeslagen?'), 'a' => __('Uitsluitend in Europese datacenters, onder Europees recht. We gebruiken geen Amerikaanse cloudproviders, waardoor je data niet onder de Amerikaanse CLOUD Act valt. Zo voldoe je aantoonbaar aan de AVG.')], - ['q' => __('Kan ik Certified eerst vrijblijvend proberen?'), 'a' => __('Zeker. Het Gratis-pakket is voor altijd gratis en laat je tot 5 certificaten beheren — zonder creditcard. Groei je eruit, dan upgrade je eenvoudig naar Starter (20 certificaten) of Onbeperkt.')], + ['q' => __('Kan ik Certified eerst vrijblijvend proberen?'), 'a' => __('Zeker. Het Gratis-pakket is voor altijd gratis en laat je tot 5 certificaten beheren — zonder creditcard. Groei je eruit, dan upgrade je eenvoudig naar Certified Starter (15 certificaten) of Certified Pro.')], ['q' => __('Heb ik een verwerkersovereenkomst nodig?'), 'a' => __('Gebruik je Certified puur voor je eigen certificaten? Dan heb je geen verwerkersovereenkomst nodig — je bent geen verwerkingsverantwoordelijke in de zin van de AVG. Werk je als zzp\'er of eenmanszaak en wil je er toch één voor je eigen administratie, dan kun je die downloaden op onze DPA-pagina.')], ]]) diff --git a/resources/views/marketing/partials/pricing-grid.blade.php b/resources/views/marketing/partials/pricing-grid.blade.php index 5aec29b..d21755d 100644 --- a/resources/views/marketing/partials/pricing-grid.blade.php +++ b/resources/views/marketing/partials/pricing-grid.blade.php @@ -1,52 +1,57 @@ +@php + use App\Enums\SubscriptionPlan; + + // Name, certificate limit, price, and whether OCR is included all come + // straight from SubscriptionPlan so this page can never drift from what + // billing actually enforces — only the hand-written marketing copy below + // (description/priceNote/cta/extra features) needs maintaining by hand. + $copy = [ + SubscriptionPlan::Free->value => [ + 'description' => __('Voor wie Certified wil proberen of maar een handvol certificaten beheert.'), + 'priceNote' => __('Voor altijd gratis, geen creditcard nodig'), + 'featured' => false, + 'cta' => __('Start gratis'), + 'features' => [ + __('Slimme e-mailreminders'), + __('Veilig delen via signed URL'), + ], + ], + SubscriptionPlan::Starter->value => [ + 'description' => __("Voor wie meer dan een handvol eigen certificaten en diploma's bijhoudt."), + 'priceNote' => __('Eén vast bedrag, jaarlijks gefactureerd'), + 'featured' => true, + 'cta' => __('Start met :plan', ['plan' => SubscriptionPlan::Starter->label()]), + 'features' => [ + __('Slimme e-mailreminders'), + __('Veilig delen via signed URL'), + __('AES-256 versleutelde ZIP-export'), + ], + ], + SubscriptionPlan::Unlimited->value => [ + 'description' => __("Voor wie al zijn diploma's, VOG's en certificaten voorgoed op één plek wil bewaken."), + 'priceNote' => __('Eén vast bedrag, jaarlijks gefactureerd'), + 'featured' => false, + 'cta' => __('Start met :plan', ['plan' => SubscriptionPlan::Unlimited->label()]), + 'features' => [ + __('Slimme e-mailreminders'), + __('Veilig delen via signed URL'), + __('AES-256 versleutelde ZIP-export'), + __('Prioriteit support'), + ], + ], + ]; +@endphp +
- @foreach ([ - [ - 'name' => __('Gratis'), - 'description' => __('Voor wie Certified wil proberen of maar een handvol certificaten beheert.'), - 'price' => '0', - 'priceNote' => __('Voor altijd gratis, geen creditcard nodig'), - 'featured' => false, - 'cta' => __('Start gratis'), - 'features' => [ - __('Maximaal 5 certificaten'), - __('Automatische OCR-scan'), - __('Slimme e-mailreminders'), - __('Veilig delen via signed URL'), - ], - ], - [ - 'name' => 'Starter', - 'description' => __("Voor wie meer dan een handvol eigen certificaten en diploma's bijhoudt."), - 'price' => '12,50', - 'priceNote' => __('Eén vast bedrag, jaarlijks gefactureerd'), - 'featured' => true, - 'cta' => __('Start met Starter'), - 'features' => [ - __('Maximaal 20 certificaten'), - __('Automatische OCR-scan'), - __('Slimme e-mailreminders'), - __('Veilig delen via signed URL'), - __('AES-256 versleutelde ZIP-export'), - ], - ], - [ - 'name' => __('Onbeperkt'), - 'description' => __("Voor wie al zijn diploma's, VOG's en certificaten voorgoed op één plek wil bewaken."), - 'price' => '45', - 'priceNote' => __('Eén vast bedrag, jaarlijks gefactureerd'), - 'featured' => false, - 'cta' => __('Start met Onbeperkt'), - 'features' => [ - __('Onbeperkt certificaten'), - __('Automatische OCR-scan'), - __('Slimme e-mailreminders'), - __('Veilig delen via signed URL'), - __('AES-256 versleutelde ZIP-export'), - __('Prioriteit support'), - ], - ], - ] as $tier) + @foreach (SubscriptionPlan::cases() as $plan) + @php + $tier = $copy[$plan->value]; + $limit = $plan->certificateLimit(); + $euros = $plan->priceInclVatCents() / 100; + $price = rtrim(rtrim(number_format($euros, 2, ',', '.'), '0'), ','); + $exclVatEuros = $plan->priceInCents() / 100; + @endphp
$tier['featured'], @@ -58,16 +63,37 @@ @endif -

{{ $tier['name'] }}

+

{{ $plan->label() }}

{{ $tier['description'] }}

- €{{ $tier['price'] }} + €{{ $price }} / {{ __('jaar') }}
+ @if ($plan->isPaid()) +

+ €{{ number_format($exclVatEuros, 2, ',', '.') }} {{ __('excl. btw') }} +

+ @endif

{{ $tier['priceNote'] }}

    +
  • + + + {{ $limit === null ? __('Onbeperkt certificaten') : __('Maximaal :count certificaten', ['count' => $limit]) }} + +
  • + @if ($plan->includesOcr()) +
  • + + {{ __('Automatische OCR-scan') }} +
  • + @endif @foreach ($tier['features'] as $feature)
$tier['featured'], @@ -91,6 +117,6 @@

- {{ __('Alle prijzen zijn per jaar en exclusief btw. Start gratis — upgraden kan altijd, opzeggen ook.') }} + {{ __('Alle prijzen zijn per jaar en inclusief 21% btw. Start gratis — upgraden kan altijd, opzeggen ook.') }}

diff --git a/resources/views/marketing/pricing.blade.php b/resources/views/marketing/pricing.blade.php index 2295b53..791d38e 100644 --- a/resources/views/marketing/pricing.blade.php +++ b/resources/views/marketing/pricing.blade.php @@ -1,7 +1,7 @@ @extends('marketing.layout') @section('title', 'Pricing — Certified') -@section('description', __('Eerlijke prijzen voor je eigen certificaatbeheer. Start gratis met maximaal 5 certificaten — inclusief OCR, slimme reminders en AVG-proof delen.')) +@section('description', __('Eerlijke prijzen voor je eigen certificaatbeheer. Start gratis met maximaal 5 certificaten — inclusief slimme reminders en AVG-proof delen.')) @section('content') @@ -31,10 +31,9 @@

{{ __('Inbegrepen bij elk pakket') }}

-
+
@foreach ([ ['title' => __('Europese hosting'), 'body' => __('Al je data in EU-datacenters, volledig AVG-proof.')], - ['title' => __('Automatische OCR'), 'body' => __('Titel, uitgever en vervaldatum automatisch herkend.')], ['title' => __('Slimme reminders'), 'body' => __('E-mail op het moment dat jij instelt, plus urgente meldingen.')], ['title' => __('Veilig delen'), 'body' => __("Ondertekende links (48u) en AES-256 versleutelde ZIP's.")], ] as $item) @@ -55,12 +54,12 @@ 'faqTitle' => __('Vragen over prijzen'), 'faqSubtitle' => __('Alles over facturering, opzeggen en overstappen.'), 'faqs' => [ - ['q' => __('Is het Gratis-pakket echt gratis?'), 'a' => __('Ja, voor altijd. Je beheert er tot 5 certificaten mee, inclusief OCR-scan, reminders en veilig delen. Geen creditcard, geen verborgen kosten — pas als je meer dan 5 certificaten wilt bewaken, stap je over op een betaald pakket.')], - ['q' => __('Hoe werkt de facturering?'), 'a' => __('Betaalde pakketten worden één keer per jaar gefactureerd: €12,50 per jaar voor Starter, €45 per jaar voor Onbeperkt, beide exclusief btw. Je ontvangt een factuur die je direct in je administratie kunt verwerken.')], + ['q' => __('Is het Gratis-pakket echt gratis?'), 'a' => __('Ja, voor altijd. Je beheert er tot 5 certificaten mee, inclusief reminders en veilig delen. Geen creditcard, geen verborgen kosten — pas als je meer dan 5 certificaten wilt bewaken, stap je over op een betaald pakket.')], + ['q' => __('Hoe werkt de facturering?'), 'a' => __('Betaalde pakketten worden één keer per jaar gefactureerd: €19 per jaar voor Certified Starter, €49 per jaar voor Certified Pro, beide inclusief 21% btw. Je ontvangt een factuur die je direct in je administratie kunt verwerken.')], ['q' => __('Wat gebeurt er als ik over mijn certificatenlimiet ga?'), 'a' => __('Niets ergs — je data blijft gewoon staan. We sturen een melding en je kunt eenvoudig upgraden naar het volgende pakket. We verwijderen nooit certificaten omdat je over een limiet heen bent.')], ['q' => __('Kan ik tussentijds upgraden, downgraden of opzeggen?'), 'a' => __('Upgraden kan op elk moment; je betaalt alleen het verschil voor de rest van het jaar. Downgraden of opzeggen gaat in bij de volgende verlengingsdatum. Na opzegging kun je al je data exporteren; daarna verwijderen we alles definitief binnen 30 dagen.')], ['q' => __('Heb ik een verwerkersovereenkomst nodig bij een betaald pakket?'), 'a' => __('Meestal niet — gebruik je Certified voor jezelf, dan ben je geen verwerkingsverantwoordelijke. Wil je er als zzp\'er of eenmanszaak toch één voor je administratie, dan staat de standaard verwerkersovereenkomst gratis voor je klaar op de DPA-pagina.')], - ['q' => __('Zijn er kosten per OCR-scan?'), 'a' => __('Nee. OCR-scans zitten bij elk pakket inbegrepen — ook bij Gratis. Je betaalt nooit per scan.')], + ['q' => __('Zijn er kosten per OCR-scan?'), 'a' => __('Nee, je betaalt nooit per scan. OCR zit bij Certified Starter en Certified Pro inbegrepen; bij het Gratis-pakket vul je certificaten handmatig in.')], ], ]) diff --git a/resources/views/marketing/terms.blade.php b/resources/views/marketing/terms.blade.php index 1dd3f8d..e3ff20d 100644 --- a/resources/views/marketing/terms.blade.php +++ b/resources/views/marketing/terms.blade.php @@ -79,7 +79,7 @@

{{ __('Artikel 5 — Abonnementen en betaling') }}

-

5.1 {{ __('Certified kent een gratis pakket en de betaalde abonnementen Starter en Onbeperkt, zoals beschreven op onze prijzenpagina. Betaalde abonnementen worden jaarlijks vooraf gefactureerd, exclusief btw.') }}

+

5.1 {{ __('Certified kent een gratis pakket en de betaalde abonnementen Certified Starter en Certified Pro, zoals beschreven op onze prijzenpagina. Betaalde abonnementen worden jaarlijks vooraf gefactureerd, inclusief 21% btw.') }}

5.2 {{ __('Upgraden kan op elk moment; je betaalt dan alleen het verschil voor de resterende looptijd.') }}

5.3 {{ __('Downgraden of opzeggen van een betaald abonnement gaat in op de eerstvolgende verlengingsdatum. Reeds betaalde bedragen worden niet naar rato terugbetaald.') }}

5.4 {{ __('We kunnen onze prijzen aanpassen met een schriftelijke kennisgeving van ten minste 30 dagen vooraf. Ga je niet akkoord met de nieuwe prijs, dan kun je opzeggen voordat de wijziging ingaat.') }}

diff --git a/resources/views/pages/certificates/⚡create.blade.php b/resources/views/pages/certificates/⚡create.blade.php index 4f1dc1a..cf95557 100644 --- a/resources/views/pages/certificates/⚡create.blade.php +++ b/resources/views/pages/certificates/⚡create.blade.php @@ -35,6 +35,13 @@ new #[Title('Add certificate')] class extends Component { public bool $scanning = false; + public bool $limitReached = false; + + public function mount(): void + { + $this->limitReached = Auth::user()->certificateLimitReached(); + } + /** * Runs when a file is selected. If OCR is enabled for the user, analyze * the uploaded document and pre-fill empty fields with suggestions. @@ -47,7 +54,7 @@ new #[Title('Add certificate')] class extends Component { $user = Auth::user(); - if ($user->ocr_provider === 'none') { + if ($user->ocr_provider === 'none' || ! $user->plan()->includesOcr()) { return; } @@ -70,6 +77,15 @@ new #[Title('Add certificate')] class extends Component { public function save(): void { + // Re-check server-side even though the form is hidden once the + // limit is reached, in case the plan's limit was hit between + // page load and submit (e.g. another tab, or a downgrade). + if (Auth::user()->certificateLimitReached()) { + $this->limitReached = true; + + return; + } + $validated = $this->validate([ 'file' => ['required', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'], 'title' => ['required', 'string', 'max:255'], @@ -138,6 +154,17 @@ new #[Title('Add certificate')] class extends Component { {{ __('Upload a document to preview it, then confirm the details — pre-filled automatically if scanning is enabled.') }}
+ @if ($limitReached) + + + {{ __('Your plan is full — upgrade to add more certificates.') }} + + + {{ __('View plans') }} + {{ __('Back to certificates') }} + + + @else
{{-- Left: live document preview (client-side, before upload completes) --}}
@@ -204,4 +231,5 @@ new #[Title('Add certificate')] class extends Component {
+ @endif
diff --git a/resources/views/pages/certificates/⚡index.blade.php b/resources/views/pages/certificates/⚡index.blade.php index 63079ad..a8b5c78 100644 --- a/resources/views/pages/certificates/⚡index.blade.php +++ b/resources/views/pages/certificates/⚡index.blade.php @@ -173,13 +173,30 @@ new #[Title('Certificates')] class extends Component { { return Category::orderBy('name')->get(); } + + #[Computed] + public function certificateLimit(): ?int + { + return Auth::user()->plan()->certificateLimit(); + } + + #[Computed] + public function certificateCount(): int + { + return Certificate::count(); + } }; ?>
{{ __('Certificates') }} - {{ __('An overview of your certificates and their expiry status.') }} + + {{ __('An overview of your certificates and their expiry status.') }} + @if ($this->certificateLimit !== null) + · {{ __(':used of :limit certificates used', ['used' => $this->certificateCount, 'limit' => $this->certificateLimit]) }} + @endif +
@@ -190,9 +207,17 @@ new #[Title('Certificates')] class extends Component { @endforeach - - {{ __('Add certificate') }} - + @if ($this->certificateLimit !== null && $this->certificateCount >= $this->certificateLimit) + + + {{ __('Add certificate') }} + + + @else + + {{ __('Add certificate') }} + + @endif
diff --git a/resources/views/pages/settings/layout.blade.php b/resources/views/pages/settings/layout.blade.php index 33ac7d8..5684537 100644 --- a/resources/views/pages/settings/layout.blade.php +++ b/resources/views/pages/settings/layout.blade.php @@ -5,6 +5,7 @@ {{ __('Security') }} {{ __('Appearance') }} {{ __('Certificates') }} + {{ __('Billing') }}
diff --git a/resources/views/pages/settings/⚡billing.blade.php b/resources/views/pages/settings/⚡billing.blade.php new file mode 100644 index 0000000..f1c4968 --- /dev/null +++ b/resources/views/pages/settings/⚡billing.blade.php @@ -0,0 +1,206 @@ + $plan], [ + 'plan' => ['required', Rule::enum(SubscriptionPlan::class)], + ])->validate(); + + $subscription = app(SubscriptionManager::class)->changePlan( + Auth::user(), + SubscriptionPlan::from($validated['plan']), + ); + + unset($this->subscription, $this->invoices); + + $unpaidInvoice = $subscription->invoices() + ->whereNotIn('state', ['paid', 'uncollectible']) + ->latest('id') + ->first(); + + if ($unpaidInvoice?->payment_url) { + Flux::toast(variant: 'success', text: __('Plan updated — complete the payment to keep your new limit.')); + $this->redirect($unpaidInvoice->payment_url, navigate: false); + + return; + } + + Flux::toast(variant: 'success', text: __('Plan updated.')); + } + + public function cancel(): void + { + app(SubscriptionManager::class)->cancel(Auth::user()); + + unset($this->subscription); + + Flux::toast(variant: 'success', text: __("Your subscription won't renew. You'll keep access until the end of the current period.")); + } + + public function resume(): void + { + app(SubscriptionManager::class)->resume(Auth::user()); + + unset($this->subscription); + + Flux::toast(variant: 'success', text: __('Cancellation undone — your subscription will renew again.')); + } + + #[Computed] + public function subscription() + { + return Auth::user()->subscription ?? Auth::user()->subscription()->create(); + } + + #[Computed] + public function usageCount(): int + { + return Auth::user()->certificates()->count(); + } + + #[Computed] + public function invoices() + { + return $this->subscription->invoices()->latest('id')->get(); + } + + /** + * @return array + */ + #[Computed] + public function plans(): array + { + return SubscriptionPlan::cases(); + } +}; ?> + +
+ @include('partials.settings-heading') + + +
+ {{-- Current status --}} +
+
+
+
+ {{ $this->subscription->plan->label() }} + {{ $this->subscription->status->label() }} +
+ @php $limit = $this->subscription->plan->certificateLimit(); @endphp + @if ($limit !== null) + + {{ __(':used of :limit certificates used', ['used' => $this->usageCount, 'limit' => $limit]) }} + + @endif +
+ + @if ($this->subscription->plan->isPaid() && $this->subscription->current_period_ends_at) +
+ @if ($this->subscription->cancel_at_period_end) + + {{ __('Ends on :date', ['date' => $this->subscription->current_period_ends_at->toFormattedDateString()]) }} + + {{ __('Undo cancellation') }} + @else + {{ __('Renews on :date', ['date' => $this->subscription->current_period_ends_at->toFormattedDateString()]) }} + + {{ __('Cancel subscription') }} + + @endif +
+ @endif +
+ + @if ($this->subscription->status === SubscriptionStatus::PastDue && $this->subscription->grace_ends_at) + + {{ __("We haven't received payment yet. Without payment before :date we'll automatically switch you back to the Free plan.", ['date' => $this->subscription->grace_ends_at->toFormattedDateString()]) }} + + @endif +
+ + {{-- Plan switcher --}} +
+ {{ __('Change plan') }} +
+ @foreach ($this->plans as $plan) + @php $isCurrent = $this->subscription->plan === $plan; @endphp +
$isCurrent, + 'border-slate-200 dark:border-slate-800' => ! $isCurrent, + ])> + {{ $plan->label() }} + + @php $planLimit = $plan->certificateLimit(); @endphp + {{ $planLimit === null ? __('Unlimited certificates') : __('Up to :count certificates', ['count' => $planLimit]) }} + + + €{{ number_format($plan->priceInclVatCents() / 100, 2, ',', '.') }}/{{ __('year') }} + + @if ($plan->isPaid()) + + €{{ number_format($plan->priceInCents() / 100, 2, ',', '.') }} {{ __('excl. VAT') }} + + @endif + @if ($isCurrent) + {{ __('Current plan') }} + @else + + {{ $plan->priceInCents() > $this->subscription->plan->priceInCents() ? __('Upgrade') : __('Switch') }} + + @endif +
+ @endforeach +
+
+ + {{-- Invoice history --}} + @if ($this->invoices->isNotEmpty()) +
+ {{ __('Invoices') }} + + + {{ __('Date') }} + {{ __('Plan') }} + {{ __('Amount') }} + {{ __('Status') }} + + + + @foreach ($this->invoices as $invoice) + + {{ $invoice->invoice_date?->toFormattedDateString() }} + {{ $invoice->plan->label() }} + €{{ number_format($invoice->amount_cents / 100, 2, ',', '.') }} + {{ $invoice->state }} + + @if ($invoice->state !== 'paid' && $invoice->payment_url) + {{ __('Pay') }} + @endif + + + @endforeach + + +
+ @endif +
+
+
diff --git a/resources/views/pages/settings/⚡certificates.blade.php b/resources/views/pages/settings/⚡certificates.blade.php index f1cefa5..5f8fdca 100644 --- a/resources/views/pages/settings/⚡certificates.blade.php +++ b/resources/views/pages/settings/⚡certificates.blade.php @@ -7,7 +7,8 @@ use Illuminate\Validation\Rule; use Livewire\Attributes\Title; use Livewire\Component; -new #[Title('Certificate settings')] class extends Component { +new #[Title('Certificate settings')] class extends Component +{ public int $reminder_months_before = 1; public string $ocr_provider = 'none'; @@ -16,6 +17,8 @@ new #[Title('Certificate settings')] class extends Component { public bool $hasApiKey = false; + public bool $ocrAvailable = false; + public function mount(): void { $user = Auth::user(); @@ -23,13 +26,22 @@ new #[Title('Certificate settings')] class extends Component { $this->reminder_months_before = $user->reminder_months_before; $this->ocr_provider = $user->ocr_provider; $this->hasApiKey = filled($user->ocr_api_key); + $this->ocrAvailable = $user->plan()->includesOcr(); + } + + /** + * @return array + */ + private function allowedOcrProviders(): array + { + return $this->ocrAvailable ? OcrManager::availableProviders() : ['none']; } public function updateSettings(): void { $validated = $this->validate([ 'reminder_months_before' => ['required', 'integer', 'min:0', 'max:24'], - 'ocr_provider' => ['required', Rule::in(OcrManager::availableProviders())], + 'ocr_provider' => ['required', Rule::in($this->allowedOcrProviders())], 'ocr_api_key' => ['nullable', 'string', 'max:255'], ]); @@ -67,12 +79,23 @@ new #[Title('Certificate settings')] class extends Component { :label="__('Remind me this many months before expiry')" /> - + {{ __('None (manual entry)') }} - {{ __('Klippa (EU / NL)') }} - {{ __('Mistral Pixtral (EU / FR)') }} + @if ($ocrAvailable) + {{ __('Klippa (EU / NL)') }} + {{ __('Mistral Pixtral (EU / FR)') }} + @endif + @unless ($ocrAvailable) + + + {{ __('Document scanning is available on Certified Starter and Certified Pro.') }} + {{ __('Upgrade your plan') }} + + + @endunless + @if ($ocr_provider !== 'none') purpose('Display an inspiring quote'); Schedule::command('app:send-certificate-reminders')->dailyAt('08:00'); +Schedule::command('app:sync-subscriptions')->dailyAt('07:30'); diff --git a/routes/settings.php b/routes/settings.php index 50752e9..e356a0a 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -13,6 +13,8 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::livewire('settings/certificates', 'pages::settings.certificates')->name('certificate-settings.edit'); + Route::livewire('settings/billing', 'pages::settings.billing')->name('billing.edit'); + Route::livewire('settings/security', 'pages::settings.security') ->middleware([ 'password.confirm', diff --git a/routes/web.php b/routes/web.php index 9fa8e5f..881dcbb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,6 +4,7 @@ use App\Http\Controllers\CertificateDownloadController; use App\Http\Controllers\CertificatePreviewController; use App\Http\Controllers\LanguageController; use App\Http\Controllers\MarketingController; +use App\Http\Controllers\MoneybirdWebhookController; use App\Http\Controllers\SharePageController; use App\Http\Controllers\VerifyController; use Illuminate\Support\Facades\Route; @@ -23,6 +24,10 @@ Route::get('language/{locale}', LanguageController::class)->name('language.switc Route::get('verify', [VerifyController::class, 'show'])->name('verify.show'); Route::post('verify', [VerifyController::class, 'check'])->middleware('throttle:10,1')->name('verify.check'); +// Moneybird calls this to report invoice/payment events. No auth — verified +// by comparing the webhook's shared token instead (see the controller). +Route::post('webhooks/moneybird', MoneybirdWebhookController::class)->name('webhooks.moneybird'); + Route::middleware(['auth', 'verified'])->group(function () { Route::livewire('dashboard', 'pages::dashboard')->name('dashboard'); diff --git a/tests/Feature/Billing/MoneybirdWebhookTest.php b/tests/Feature/Billing/MoneybirdWebhookTest.php new file mode 100644 index 0000000..7e834cc --- /dev/null +++ b/tests/Feature/Billing/MoneybirdWebhookTest.php @@ -0,0 +1,91 @@ + 'test-token', + 'services.moneybird.administration_id' => '123456', + 'services.moneybird.webhook_token' => 'the-webhook-token', + ]); +}); + +test('a request with the wrong webhook token is rejected', function () { + Http::fake(); + + $user = User::factory()->create(); + Subscription::factory()->for($user)->starter()->create(['moneybird_contact_id' => 'contact-1']); + + $this->post(route('webhooks.moneybird'), [ + 'webhook_token' => 'not-the-right-token', + 'entity_type' => 'SalesInvoice', + 'entity_id' => 'invoice-1', + ])->assertForbidden(); + + Http::assertNothingSent(); +}); + +test('a paid sales invoice activates the matching subscription', function () { + Notification::fake(); + + Http::fake([ + '*/sales_invoices/invoice-1.json' => Http::response([ + 'id' => 'invoice-1', + 'contact_id' => 'contact-1', + 'state' => 'paid', + 'invoice_date' => today()->toDateString(), + 'due_date' => today()->addDays(14)->toDateString(), + 'paid_at' => now()->toIso8601String(), + 'total_price_excl_tax' => number_format(SubscriptionPlan::Starter->priceInCents() / 100, 2, '.', ''), + ]), + ]); + + $user = User::factory()->create(); + $subscription = Subscription::factory()->for($user)->starter()->create([ + 'moneybird_contact_id' => 'contact-1', + 'status' => SubscriptionStatus::AwaitingPayment, + 'current_period_starts_at' => today(), + 'current_period_ends_at' => today()->addYear(), + ]); + + $this->post(route('webhooks.moneybird'), [ + 'webhook_token' => 'the-webhook-token', + 'entity_type' => 'SalesInvoice', + 'entity_id' => 'invoice-1', + ])->assertNoContent(); + + expect($subscription->refresh()->status)->toBe(SubscriptionStatus::Active) + ->and($subscription->grace_ends_at)->toBeNull(); + + $invoice = SubscriptionInvoice::sole(); + expect($invoice->moneybird_invoice_id)->toBe('invoice-1') + ->and($invoice->state)->toBe('paid') + ->and($invoice->paid_at)->not->toBeNull(); + + Notification::assertSentTo($user, SubscriptionActivatedNotification::class); +}); + +test('an invoice for an unknown contact is ignored without error', function () { + Http::fake([ + '*/sales_invoices/invoice-1.json' => Http::response([ + 'id' => 'invoice-1', + 'contact_id' => 'someone-elses-contact', + 'state' => 'paid', + ]), + ]); + + $this->post(route('webhooks.moneybird'), [ + 'webhook_token' => 'the-webhook-token', + 'entity_type' => 'SalesInvoice', + 'entity_id' => 'invoice-1', + ])->assertNoContent(); + + expect(SubscriptionInvoice::count())->toBe(0); +}); diff --git a/tests/Feature/Billing/SubscriptionPlanChangeTest.php b/tests/Feature/Billing/SubscriptionPlanChangeTest.php new file mode 100644 index 0000000..cd3c00b --- /dev/null +++ b/tests/Feature/Billing/SubscriptionPlanChangeTest.php @@ -0,0 +1,137 @@ + 'test-token', + 'services.moneybird.administration_id' => '123456', + ]); +}); + +test('switching from free to starter creates a contact, invoice, and recurring template', function () { + Http::fake([ + '*/contacts/customer_id/*' => Http::response('', 404), + '*/contacts.json' => Http::response(['id' => 'contact-1'], 201), + '*/sales_invoices.json' => Http::response([ + 'id' => 'invoice-1', + 'state' => 'open', + 'invoice_date' => today()->toDateString(), + 'due_date' => today()->addDays(14)->toDateString(), + 'payment_url' => 'https://moneybird.dev/pay/invoice-1', + ], 201), + '*/recurring_sales_invoices.json' => Http::response(['id' => 'recurring-1'], 201), + ]); + + $user = User::factory()->create(); + + $subscription = app(SubscriptionManager::class)->changePlan($user, SubscriptionPlan::Starter); + + expect($subscription->plan)->toBe(SubscriptionPlan::Starter) + ->and($subscription->status)->toBe(SubscriptionStatus::AwaitingPayment) + ->and($subscription->moneybird_contact_id)->toBe('contact-1') + ->and($subscription->moneybird_recurring_invoice_id)->toBe('recurring-1') + ->and($subscription->current_period_starts_at->toDateString())->toBe(today()->toDateString()) + ->and($subscription->current_period_ends_at->toDateString())->toBe(today()->addYear()->toDateString()); + + $invoice = SubscriptionInvoice::sole(); + expect($invoice->moneybird_invoice_id)->toBe('invoice-1') + ->and($invoice->amount_cents)->toBe(SubscriptionPlan::Starter->priceInCents()) + ->and($invoice->payment_url)->toBe('https://moneybird.dev/pay/invoice-1'); + + Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/contacts.json') + && $request['contact']['customer_id'] === (string) $user->id); +}); + +test('an existing moneybird contact is reused instead of creating a duplicate', function () { + Http::fake([ + '*/contacts/customer_id/*' => Http::response(['id' => 'existing-contact'], 200), + '*/sales_invoices.json' => Http::response(['id' => 'invoice-1', 'state' => 'open'], 201), + '*/recurring_sales_invoices.json' => Http::response(['id' => 'recurring-1'], 201), + ]); + + $user = User::factory()->create(); + + $subscription = app(SubscriptionManager::class)->changePlan($user, SubscriptionPlan::Starter); + + expect($subscription->moneybird_contact_id)->toBe('existing-contact'); + Http::assertNotSent(fn ($request) => str_contains((string) $request->url(), '/contacts.json') && $request->method() === 'POST'); +}); + +test('upgrading between paid plans bills a prorated top-up and repoints the recurring template', function () { + Http::fake([ + '*/sales_invoices.json' => Http::response([ + 'id' => 'invoice-2', + 'state' => 'open', + 'invoice_date' => today()->toDateString(), + ], 201), + '*/recurring_sales_invoices/recurring-1.json' => Http::response(['id' => 'recurring-1'], 200), + ]); + + $user = User::factory()->create(); + $subscription = Subscription::factory()->for($user)->starter()->create([ + 'moneybird_contact_id' => 'contact-1', + 'moneybird_recurring_invoice_id' => 'recurring-1', + 'current_period_starts_at' => today(), + 'current_period_ends_at' => today()->addYear(), + ]); + + $updated = app(SubscriptionManager::class)->changePlan($user, SubscriptionPlan::Unlimited); + + expect($updated->plan)->toBe(SubscriptionPlan::Unlimited) + ->and($updated->status)->toBe(SubscriptionStatus::AwaitingPayment) + // Full year remaining, so the prorated top-up is the full price diff. + ->and($updated->current_period_ends_at->toDateString())->toBe($subscription->current_period_ends_at->toDateString()); + + $invoice = SubscriptionInvoice::sole(); + expect($invoice->amount_cents)->toBe(SubscriptionPlan::Unlimited->priceInCents() - SubscriptionPlan::Starter->priceInCents()); + + Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/recurring_sales_invoices/recurring-1.json') + && $request->method() === 'PATCH'); +}); + +test('downgrading to a cheaper paid plan does not create an invoice', function () { + Http::fake([ + '*/recurring_sales_invoices/recurring-1.json' => Http::response(['id' => 'recurring-1'], 200), + ]); + + $user = User::factory()->create(); + Subscription::factory()->for($user)->unlimited()->create([ + 'moneybird_recurring_invoice_id' => 'recurring-1', + 'current_period_starts_at' => today(), + 'current_period_ends_at' => today()->addYear(), + ]); + + $updated = app(SubscriptionManager::class)->changePlan($user, SubscriptionPlan::Starter); + + expect($updated->plan)->toBe(SubscriptionPlan::Starter); + expect(SubscriptionInvoice::count())->toBe(0); + Http::assertNotSent(fn ($request) => str_contains((string) $request->url(), '/sales_invoices.json')); +}); + +test('switching to free deletes the recurring template and resets the subscription', function () { + Http::fake([ + '*/recurring_sales_invoices/recurring-1.json' => Http::response('', 204), + ]); + + $user = User::factory()->create(); + Subscription::factory()->for($user)->starter()->create([ + 'moneybird_recurring_invoice_id' => 'recurring-1', + ]); + + $subscription = app(SubscriptionManager::class)->changePlan($user, SubscriptionPlan::Free); + + expect($subscription->plan)->toBe(SubscriptionPlan::Free) + ->and($subscription->status)->toBe(SubscriptionStatus::Active) + ->and($subscription->moneybird_recurring_invoice_id)->toBeNull() + ->and($subscription->current_period_ends_at)->toBeNull(); + + Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/recurring_sales_invoices/recurring-1.json') + && $request->method() === 'DELETE'); +}); diff --git a/tests/Feature/Billing/SubscriptionSyncTest.php b/tests/Feature/Billing/SubscriptionSyncTest.php new file mode 100644 index 0000000..2341275 --- /dev/null +++ b/tests/Feature/Billing/SubscriptionSyncTest.php @@ -0,0 +1,92 @@ + 'test-token', + 'services.moneybird.administration_id' => '123456', + 'services.moneybird.grace_days' => 14, + ]); +}); + +test('an overdue awaiting-payment subscription is marked past due with a grace period', function () { + $user = User::factory()->create(); + $subscription = Subscription::factory()->for($user)->starter()->create([ + 'status' => SubscriptionStatus::AwaitingPayment, + ]); + + SubscriptionInvoice::factory()->for($subscription)->create([ + 'state' => 'open', + 'due_date' => today()->subDay(), + ]); + + $this->artisan('app:sync-subscriptions')->assertSuccessful(); + + $subscription->refresh(); + expect($subscription->status)->toBe(SubscriptionStatus::PastDue) + ->and($subscription->grace_ends_at?->toDateString())->toBe(today()->addDays(14)->toDateString()); +}); + +test('a past-due subscription is downgraded to free once its grace period lapses', function () { + Notification::fake(); + Http::fake([ + '*/recurring_sales_invoices/recurring-1.json' => Http::response('', 204), + ]); + + $user = User::factory()->create(); + $subscription = Subscription::factory()->for($user)->starter()->pastDue()->create([ + 'moneybird_recurring_invoice_id' => 'recurring-1', + ]); + + $this->artisan('app:sync-subscriptions')->assertSuccessful(); + + expect($subscription->refresh()->plan)->toBe(SubscriptionPlan::Free) + ->and($subscription->status)->toBe(SubscriptionStatus::Active) + ->and($subscription->moneybird_recurring_invoice_id)->toBeNull(); + + Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/recurring_sales_invoices/recurring-1.json') + && $request->method() === 'DELETE'); + Notification::assertSentTo($user, SubscriptionDowngradedNotification::class); +}); + +test('a cancelled subscription is downgraded to free once the current period ends', function () { + Notification::fake(); + Http::fake([ + '*/recurring_sales_invoices/*' => Http::response('', 404), + ]); + + $user = User::factory()->create(); + $subscription = Subscription::factory()->for($user)->starter()->create([ + 'cancel_at_period_end' => true, + 'current_period_ends_at' => today()->subDay(), + ]); + + $this->artisan('app:sync-subscriptions')->assertSuccessful(); + + expect($subscription->refresh()->plan)->toBe(SubscriptionPlan::Free) + ->and($subscription->cancel_at_period_end)->toBeFalse(); + + Notification::assertSentTo($user, SubscriptionDowngradedNotification::class); +}); + +test('subscriptions in good standing are left untouched', function () { + Notification::fake(); + + $user = User::factory()->create(); + $subscription = Subscription::factory()->for($user)->starter()->create(); + + $this->artisan('app:sync-subscriptions')->assertSuccessful(); + + expect($subscription->refresh()->status)->toBe(SubscriptionStatus::Active) + ->and($subscription->plan)->toBe(SubscriptionPlan::Starter); + + Notification::assertNothingSent(); +}); diff --git a/tests/Feature/Certificates/CertificateIndexTest.php b/tests/Feature/Certificates/CertificateIndexTest.php index 8fa01a9..b3136be 100644 --- a/tests/Feature/Certificates/CertificateIndexTest.php +++ b/tests/Feature/Certificates/CertificateIndexTest.php @@ -115,3 +115,26 @@ test('expired certificates show an expired badge', function () { $this->get(route('certificates.index'))->assertSee('Expired'); }); + +test('the free plan usage is shown and the add button is enabled below the limit', function () { + $this->actingAs($user = User::factory()->create()); + + Certificate::factory()->for($user)->count(3)->create(); + + $this->get(route('certificates.index')) + ->assertOk() + ->assertSee('3') + ->assertSee('5') + ->assertSee(route('certificates.create'), false); +}); + +test('the add button links to pricing once the free plan limit is reached', function () { + $this->actingAs($user = User::factory()->create()); + + Certificate::factory()->for($user)->count(5)->create(); + + $this->get(route('certificates.index')) + ->assertOk() + ->assertSee(route('marketing.pricing'), false) + ->assertDontSee(route('certificates.create'), false); +}); diff --git a/tests/Feature/Certificates/CertificateUploadTest.php b/tests/Feature/Certificates/CertificateUploadTest.php index c31c091..68aa34e 100644 --- a/tests/Feature/Certificates/CertificateUploadTest.php +++ b/tests/Feature/Certificates/CertificateUploadTest.php @@ -1,5 +1,7 @@ create(); + Subscription::factory()->for($user)->starter()->create(); $user->ocr_provider = 'klippa'; $user->ocr_api_key = 'key'; $user->save(); @@ -102,6 +105,29 @@ test('ocr suggestions pre-fill empty fields on upload', function () { ->assertSet('expires_at', '2028-01-15'); }); +test('ocr is skipped for a free plan user even if an ocr provider is set', function () { + Http::fake([ + 'custom-ocr.klippa.com/*' => Http::response([ + 'data' => ['parsed' => ['document_subject' => 'Scanned Title']], + ]), + ]); + + // A free-plan user can end up with a stale ocr_provider (e.g. after a + // downgrade); scanning must stay off regardless of that setting. + $user = User::factory()->create(); + $user->ocr_provider = 'klippa'; + $user->ocr_api_key = 'key'; + $user->save(); + + $this->actingAs($user); + + Livewire::test('pages::certificates.create') + ->set('file', UploadedFile::fake()->create('cert.pdf', 200, 'application/pdf')) + ->assertSet('title', ''); + + Http::assertNothingSent(); +}); + test('ocr does not overwrite fields the user already filled', function () { Http::fake([ 'custom-ocr.klippa.com/*' => Http::response([ @@ -110,6 +136,7 @@ test('ocr does not overwrite fields the user already filled', function () { ]); $user = User::factory()->create(); + Subscription::factory()->for($user)->starter()->create(); $user->ocr_provider = 'klippa'; $user->ocr_api_key = 'key'; $user->save(); @@ -121,3 +148,56 @@ test('ocr does not overwrite fields the user already filled', function () { ->set('file', UploadedFile::fake()->create('cert.pdf', 200, 'application/pdf')) ->assertSet('title', 'My Own Title'); }); + +test('a free plan user is blocked from adding a certificate past their limit', function () { + $user = User::factory()->create(); + Certificate::factory()->for($user)->count(5)->create(); + + $this->actingAs($user); + + $this->get(route('certificates.create')) + ->assertOk() + ->assertSee('Certificate limit reached'); + + Livewire::test('pages::certificates.create') + ->set('file', UploadedFile::fake()->create('cert.pdf', 200, 'application/pdf')) + ->set('title', 'One Too Many') + ->set('expires_at', now()->addYear()->toDateString()) + ->call('save'); + + expect($user->certificates()->count())->toBe(5); +}); + +test('a starter plan user is blocked once they reach their higher limit', function () { + $user = User::factory()->create(); + Subscription::factory()->for($user)->starter()->create(); + Certificate::factory()->for($user)->count(15)->create(); + + $this->actingAs($user); + + Livewire::test('pages::certificates.create') + ->set('file', UploadedFile::fake()->create('cert.pdf', 200, 'application/pdf')) + ->set('title', 'One Too Many') + ->set('expires_at', now()->addYear()->toDateString()) + ->call('save'); + + expect($user->certificates()->count())->toBe(15); +}); + +test('an unlimited plan user is never blocked by the certificate limit', function () { + $user = User::factory()->create(); + Subscription::factory()->for($user)->unlimited()->create(); + Certificate::factory()->for($user)->count(20)->create(); + + $this->actingAs($user); + + Livewire::test('pages::certificates.create') + ->set('file', UploadedFile::fake()->create('cert.pdf', 200, 'application/pdf')) + ->set('title', 'Number 21') + ->set('expires_at', now()->addYear()->toDateString()) + ->call('save') + ->assertHasNoErrors() + ->assertRedirect(route('certificates.index')); + + expect($user->certificates()->count())->toBe(21); +}); diff --git a/tests/Feature/Settings/CertificateSettingsTest.php b/tests/Feature/Settings/CertificateSettingsTest.php index b343319..2686b0b 100644 --- a/tests/Feature/Settings/CertificateSettingsTest.php +++ b/tests/Feature/Settings/CertificateSettingsTest.php @@ -1,5 +1,6 @@ get(route('certificate-settings.edit'))->assertOk(); }); -test('a user can update their reminder and ocr settings', function () { +test('a user on a paid plan can update their reminder and ocr settings', function () { $user = User::factory()->create(); + Subscription::factory()->for($user)->starter()->create(); $this->actingAs($user); Livewire::test('pages::settings.certificates') @@ -29,6 +31,7 @@ test('a user can update their reminder and ocr settings', function () { test('a blank api key keeps the existing one', function () { $user = User::factory()->create(); + Subscription::factory()->for($user)->starter()->create(); $user->ocr_provider = 'klippa'; $user->ocr_api_key = 'original-key'; $user->save(); @@ -46,10 +49,36 @@ test('a blank api key keeps the existing one', function () { }); test('an unknown ocr provider is rejected', function () { - $this->actingAs(User::factory()->create()); + $user = User::factory()->create(); + Subscription::factory()->for($user)->starter()->create(); + $this->actingAs($user); Livewire::test('pages::settings.certificates') ->set('ocr_provider', 'hacker') ->call('updateSettings') ->assertHasErrors(['ocr_provider']); }); + +test('a free plan user cannot select a paid ocr provider', function () { + // Free plan users have no subscription row (falls back to Free). + $this->actingAs(User::factory()->create()); + + Livewire::test('pages::settings.certificates') + ->assertSee('Certified Starter') + ->set('ocr_provider', 'klippa') + ->call('updateSettings') + ->assertHasErrors(['ocr_provider']); +}); + +test('a free plan user keeps ocr disabled after saving other settings', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test('pages::settings.certificates') + ->set('reminder_months_before', 3) + ->set('ocr_provider', 'none') + ->call('updateSettings') + ->assertHasNoErrors(); + + expect($user->refresh()->ocr_provider)->toBe('none'); +});