Added subscriptions with Moneybird

This commit is contained in:
Joël van de Wouw 2026-08-09 16:33:53 +02:00
parent 5e52785524
commit 4d07b19eed
42 changed files with 2279 additions and 90 deletions

View file

@ -56,6 +56,13 @@ MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
MONEYBIRD_API_TOKEN="<token>"
MONEYBIRD_ADMINISTRATION_ID=
MONEYBIRD_WEBHOOK_TOKEN=
MONEYBIRD_TAX_RATE_ID=
MONEYBIRD_LEDGER_ACCOUNT_ID=
MONEYBIRD_GRACE_DAYS=14
AWS_ACCESS_KEY_ID= AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1 AWS_DEFAULT_REGION=us-east-1

View file

@ -24,10 +24,14 @@ class CreateNewUser implements CreatesNewUsers
'password' => $this->passwordRules(), 'password' => $this->passwordRules(),
])->validate(); ])->validate();
return User::create([ $user = User::create([
'name' => $input['name'], 'name' => $input['name'],
'email' => $input['email'], 'email' => $input['email'],
'password' => $input['password'], 'password' => $input['password'],
]); ]);
$user->subscription()->create();
return $user;
} }
} }

View file

@ -0,0 +1,124 @@
<?php
namespace App\Console\Commands;
use App\Services\Moneybird\MoneybirdClient;
use App\Services\Moneybird\MoneybirdException;
use Illuminate\Console\Command;
/**
* One-time interactive helper for wiring up the Moneybird integration.
* Never writes .env itself it only prints the values so you can decide
* what to do with them.
*/
class MoneybirdSetup extends Command
{
protected $signature = 'moneybird:setup';
protected $description = 'Discover your Moneybird administration/tax rate/ledger account IDs and (optionally) register the webhook';
public function handle(MoneybirdClient $client): int
{
if (blank(config('services.moneybird.api_token'))) {
$this->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: <info>{$administrationId}</info>");
// 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'].'"');
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Console\Commands;
use App\Services\Billing\SubscriptionManager;
use Illuminate\Console\Command;
class SyncSubscriptions extends Command
{
protected $signature = 'app:sync-subscriptions';
protected $description = 'Safety-net sweep for subscription billing: flag overdue invoices as past due, and downgrade lapsed/cancelled subscriptions to Free';
public function handle(SubscriptionManager $subscriptions): int
{
$result = $subscriptions->syncSubscriptions();
$this->info("Marked {$result['pastDue']} subscription(s) past due and downgraded {$result['downgraded']} to Free.");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace App\Enums;
/**
* The billable tiers advertised on the pricing page. Prices are the annual
* amount in cents (EUR); certificateLimit() is null for unlimited plans.
*/
enum SubscriptionPlan: string
{
case Free = 'free';
case Starter = 'starter';
case Unlimited = 'unlimited';
/**
* The VAT rate baked into the advertised (incl. VAT) prices below.
*/
private const VAT_RATE = 0.21;
public function label(): string
{
return match ($this) {
self::Free => __('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;
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Enums;
enum SubscriptionStatus: string
{
/** Free plan, or a paid plan whose latest invoice is paid. */
case Active = 'active';
/** A paid plan's invoice was created and is still within its due date. */
case AwaitingPayment = 'awaiting_payment';
/** A paid plan's invoice is overdue; within the grace period. */
case PastDue = 'past_due';
public function label(): string
{
return match ($this) {
self::Active => __('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',
};
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers;
use App\Services\Billing\SubscriptionManager;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
/**
* Receives Moneybird's webhook POSTs (see `php artisan moneybird:setup`).
* Moneybird includes the webhook's `webhook_token` in every delivery so we
* can confirm it's genuinely from the subscription we registered — that's
* verified with a constant-time comparison rather than an HMAC signature,
* since Moneybird's webhook docs describe a shared token, not a signed
* payload.
*
* The exact POST field names below (`webhook_token`, `entity_type`,
* `entity_id`) are Moneybird's documented webhook-delivery fields, but
* weren't independently confirmed against a live delivery while building
* this the raw payload is logged so that's easy to double-check the
* first time a real event comes in, and the daily `app:sync-subscriptions`
* sweep means nothing breaks even if this needs adjusting.
*/
class MoneybirdWebhookController extends Controller
{
public function __invoke(Request $request, SubscriptionManager $subscriptions): Response
{
Log::info('Moneybird webhook received.', $request->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();
}
}

View file

@ -0,0 +1,74 @@
<?php
namespace App\Models;
use App\Enums\SubscriptionPlan;
use App\Enums\SubscriptionStatus;
use Database\Factories\SubscriptionFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property int $user_id
* @property SubscriptionPlan $plan
* @property SubscriptionStatus $status
* @property string|null $moneybird_contact_id
* @property string|null $moneybird_recurring_invoice_id
* @property Carbon|null $current_period_starts_at
* @property Carbon|null $current_period_ends_at
* @property Carbon|null $grace_ends_at
* @property bool $cancel_at_period_end
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
#[Fillable(['plan', 'status', 'moneybird_contact_id', 'moneybird_recurring_invoice_id', 'current_period_starts_at', 'current_period_ends_at', 'grace_ends_at', 'cancel_at_period_end'])]
class Subscription extends Model
{
/** @use HasFactory<SubscriptionFactory> */
use HasFactory;
/**
* @var array<string, mixed>
*/
protected $attributes = [
'plan' => 'free',
'status' => 'active',
'cancel_at_period_end' => false,
];
/**
* @return array<string, string>
*/
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<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* @return HasMany<SubscriptionInvoice, $this>
*/
public function invoices(): HasMany
{
return $this->hasMany(SubscriptionInvoice::class);
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace App\Models;
use App\Enums\SubscriptionPlan;
use Database\Factories\SubscriptionInvoiceFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* A snapshot of a Moneybird sales invoice tied to a subscription one row
* per invoice we've seen, kept in sync via webhook (and the daily sync
* command as a fallback) so the billing page can show payment history
* without calling Moneybird on every page load.
*
* @property int $id
* @property int $subscription_id
* @property string $moneybird_invoice_id
* @property SubscriptionPlan $plan
* @property int $amount_cents
* @property string $state
* @property Carbon|null $invoice_date
* @property Carbon|null $due_date
* @property Carbon|null $paid_at
* @property string|null $payment_url
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
#[Fillable(['moneybird_invoice_id', 'plan', 'amount_cents', 'state', 'invoice_date', 'due_date', 'paid_at', 'payment_url'])]
class SubscriptionInvoice extends Model
{
/** @use HasFactory<SubscriptionInvoiceFactory> */
use HasFactory;
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'plan' => SubscriptionPlan::class,
'invoice_date' => 'date',
'due_date' => 'date',
'paid_at' => 'datetime',
];
}
/**
* @return BelongsTo<Subscription, $this>
*/
public function subscription(): BelongsTo
{
return $this->belongsTo(Subscription::class);
}
}

View file

@ -3,11 +3,13 @@
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail; // use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Enums\SubscriptionPlan;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@ -80,6 +82,35 @@ class User extends Authenticatable implements PasskeyUser
return $this->hasMany(Category::class); return $this->hasMany(Category::class);
} }
/**
* @return HasOne<Subscription, $this>
*/
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 * Get the user's initials
*/ */

View file

@ -0,0 +1,38 @@
<?php
namespace App\Notifications;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class SubscriptionActivatedNotification extends Notification
{
use Queueable;
public function __construct(public Subscription $subscription) {}
/**
* @return array<int, string>
*/
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'));
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Notifications;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class SubscriptionDowngradedNotification extends Notification
{
use Queueable;
public function __construct(public Subscription $subscription) {}
/**
* @return array<int, string>
*/
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'));
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace App\Notifications;
use App\Models\Subscription;
use App\Models\SubscriptionInvoice;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class SubscriptionPaymentDueNotification extends Notification
{
use Queueable;
public function __construct(public Subscription $subscription, public SubscriptionInvoice $invoice) {}
/**
* @return array<int, string>
*/
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."));
}
}

View file

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

View file

@ -0,0 +1,165 @@
<?php
namespace App\Services\Moneybird;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Thin wrapper around the Moneybird REST API (https://developer.moneybird.com)
* exposing only the calls subscription billing needs. Every method returns
* plain decoded-JSON arrays rather than a bespoke SDK's value objects, and
* throws MoneybirdException on unexpected failures.
*/
class MoneybirdClient
{
private function http(): PendingRequest
{
$administrationId = config('services.moneybird.administration_id');
if (blank(config('services.moneybird.api_token')) || blank($administrationId)) {
throw new MoneybirdException('Moneybird is not configured — set MONEYBIRD_API_TOKEN and MONEYBIRD_ADMINISTRATION_ID (see `php artisan moneybird:setup`).');
}
return Http::withToken(config('services.moneybird.api_token'))
->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<string, mixed> $attributes
*/
public function createContact(array $attributes): array
{
$response = $this->http()->post('contacts.json', ['contact' => $attributes]);
return $this->decode($response, 'POST contacts');
}
/**
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
public function listTaxRates(): array
{
$response = $this->http()->get('tax_rates.json');
return $this->decode($response, 'GET tax_rates');
}
/**
* @return array<int, array<string, mixed>>
*/
public function listLedgerAccounts(): array
{
$response = $this->http()->get('ledger_accounts.json');
return $this->decode($response, 'GET ledger_accounts');
}
/**
* @param array<string, mixed> $attributes
*/
public function createWebhook(array $attributes): array
{
$response = $this->http()->post('webhooks.json', $attributes);
return $this->decode($response, 'POST webhooks');
}
/**
* @return array<mixed>
*/
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() ?? [];
}
}

View file

@ -0,0 +1,7 @@
<?php
namespace App\Services\Moneybird;
use RuntimeException;
class MoneybirdException extends RuntimeException {}

View file

@ -14,6 +14,36 @@ return Application::configure(basePath: dirname(__DIR__))
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [SetLocale::class]); $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 { ->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen( $exceptions->shouldRenderJsonWhen(

View file

@ -44,4 +44,19 @@ return [
'secret_key' => env('TURNSTILE_SECRET_KEY'), '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),
],
]; ];

View file

@ -0,0 +1,57 @@
<?php
namespace Database\Factories;
use App\Enums\SubscriptionPlan;
use App\Enums\SubscriptionStatus;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Subscription>
*/
class SubscriptionFactory extends Factory
{
/**
* @return array<string, mixed>
*/
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(),
]);
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace Database\Factories;
use App\Enums\SubscriptionPlan;
use App\Models\Subscription;
use App\Models\SubscriptionInvoice;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<SubscriptionInvoice>
*/
class SubscriptionInvoiceFactory extends Factory
{
/**
* @return array<string, mixed>
*/
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(),
]);
}
}

View file

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('subscriptions', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('subscription_invoices', function (Blueprint $table) {
$table->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');
}
};

View file

@ -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.", "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", "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 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.", "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.", "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.", "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 8 — Gegevens, privacy en OCR-verwerking": "Article 8 — Data, privacy, and OCR processing",
"Artikel 9 — Aansprakelijkheid": "Article 9 — Liability", "Artikel 9 — Aansprakelijkheid": "Article 9 — Liability",
"Artikel 9 — Meldplicht datalekken": "Article 9 — Data breach notification", "Artikel 9 — Meldplicht datalekken": "Article 9 — Data breach notification",
"Automatische OCR": "Automatic OCR",
"Automatische OCR-scan": "Automatic OCR scan", "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.", "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.", "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.", "Bckstage, die persoonsgegevens verwerkt namens de verwerkingsverantwoordelijke.": "Bckstage, which processes personal data on behalf of the controller.",
"Bekijk de prijzen": "View pricing", "Bekijk de prijzen": "View pricing",
"Bekijk de verwerkersovereenkomst": "View the Data Processing Agreement", "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", "Beveiliging": "Security",
"Beveiligingslogboeken": "Security logs", "Beveiligingslogboeken": "Security logs",
"Bezorging van reminders en accountmeldingen": "Delivery of reminders and account notifications", "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 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 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 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 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", "Certified — home": "Certified — home",
"Contact": "Contact", "Contact": "Contact",
@ -136,7 +135,7 @@
"EHBO & BHV": "First aid & emergency response", "EHBO & BHV": "First aid & emergency response",
"EU (Nederland)": "EU (Netherlands)", "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.", "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", "Eerlijke prijzen, geen verrassingen": "Fair pricing, no surprises",
"Eigen infrastructuur": "Own infrastructure", "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", "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 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?", "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, 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.", "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 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.", "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 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 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 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.", "Je verklaart dat je ten minste 16 jaar oud bent.": "You declare that you are at least 16 years old.",
"Jij": "You", "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.", "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", "Locatie": "Location",
"Login": "Login", "Login": "Login",
"Malware, virussen of schadelijke code uploaden of verspreiden via de Dienst": "Uploading or spreading malware, viruses, or harmful code via the Service", "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 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", "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.", "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", "Menu openen": "Open menu",
@ -228,7 +226,7 @@
"Nederland (EU)": "Netherlands (EU)", "Nederland (EU)": "Netherlands (EU)",
"Nederlands recht, EU-hosting": "Dutch law, EU hosting", "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. 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", "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 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.", "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-herkenning — alleen indien geactiveerd": "OCR recognition — only if activated",
"OCR-verwerking": "OCR processing", "OCR-verwerking": "OCR processing",
"OCR-verwerking (optioneel)": "OCR processing (optional)", "OCR-verwerking (optioneel)": "OCR processing (optional)",
"Onbeperkt": "Unlimited",
"Onbeperkt certificaten": "Unlimited certificates", "Onbeperkt certificaten": "Unlimited certificates",
"Onbeveiligd verstuurd — een onnodig risico op misbruik van je gegevens": "Sent unsecured — an unnecessary risk of misuse of your data", "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.", "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.", "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.", "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": "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 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 Onbeperkt": "Start with Unlimited", "Start met :plan": "Start with :plan",
"Start met Starter": "Start with Starter",
"Status": "Status", "Status": "Status",
"Stop met certificaten mailen als losse bijlage": "Stop emailing certificates as loose attachments", "Stop met certificaten mailen als losse bijlage": "Stop emailing certificates as loose attachments",
"Strikte scheiding tussen accounts": "Strict separation between accounts", "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.", "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", "Wijzigingen": "Changes",
"Wissel tussen licht en donker thema": "Switch between light and dark theme", "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?", "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.", "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.", "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.", "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 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.", "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", "jaar": "year",
"niet als bijzaak": "not an afterthought", "niet als bijzaak": "not an afterthought",
"oude manier": "old way", "oude manier": "old way",

View file

@ -9,6 +9,7 @@
":count time|:count times": ":count keer|:count keer", ":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 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", ":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 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 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).", "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 details": "Certificaatgegevens",
"Certificate expires today: :title": "Certificaat verloopt vandaag: :title", "Certificate expires today: :title": "Certificaat verloopt vandaag: :title",
"Certificate expiring soon: :title": "Certificaat verloopt binnenkort: :title", "Certificate expiring soon: :title": "Certificaat verloopt binnenkort: :title",
"Certificate limit reached": "Certificaatlimiet bereikt",
"Certificate number": "Certificaatnummer", "Certificate number": "Certificaatnummer",
"Certificate settings updated.": "Certificaatinstellingen bijgewerkt.", "Certificate settings updated.": "Certificaatinstellingen bijgewerkt.",
"Certificate updated.": "Certificaat bijgewerkt.", "Certificate updated.": "Certificaat bijgewerkt.",
@ -75,6 +77,7 @@
"Disable 2FA": "2FA uitschakelen", "Disable 2FA": "2FA uitschakelen",
"Document preview": "Documentvoorbeeld", "Document preview": "Documentvoorbeeld",
"Document scanning (OCR)": "Documentscannen (OCR)", "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", "Documentation": "Documentatie",
"Don't have an account?": "Nog geen account?", "Don't have an account?": "Nog geen account?",
"Done": "Klaar", "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 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 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", "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", "Expired": "Verlopen",
"Expires": "Vervalt", "Expires": "Vervalt",
"Expiring soon": "Vervalt binnenkort", "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 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 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", "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 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.", "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…", "Uploading…": "Uploaden…",
@ -264,6 +270,7 @@
"Verify a certificate": "Certificaat verifiëren", "Verify a certificate": "Certificaat verifiëren",
"Verify authentication code": "Authenticatiecode verifiëren", "Verify authentication code": "Authenticatiecode verifiëren",
"View all": "Alles bekijken", "View all": "Alles bekijken",
"View plans": "Bekijk pakketten",
"View recovery codes": "Herstelcodes bekijken", "View recovery codes": "Herstelcodes bekijken",
"View shared certificates": "Bekijk gedeelde certificaten", "View shared certificates": "Bekijk gedeelde certificaten",
"View your certificates": "Bekijk je 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.", "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\" expires today.": "Je certificaat \":title\" verloopt vandaag.",
"Your certificate \":title\" will expire on :date.": "Je certificaat \":title\" verloopt op :date.", "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"
} }

View file

@ -494,7 +494,7 @@
{{ __('Eerlijke prijzen, geen verrassingen') }} {{ __('Eerlijke prijzen, geen verrassingen') }}
</h2> </h2>
<p class="mt-4 text-lg text-slate-500 dark:text-slate-400"> <p class="mt-4 text-lg text-slate-500 dark:text-slate-400">
{{ __('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.') }}
</p> </p>
</div> </div>
@ -505,10 +505,10 @@
{{-- ============ FAQ ============ --}} {{-- ============ FAQ ============ --}}
@include('marketing.partials.faq', ['faqs' => [ @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' => __('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' => __('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' => __('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.')], ['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.')],
]]) ]])

View file

@ -1,52 +1,57 @@
<div> @php
<div class="grid gap-8 lg:grid-cols-3 lg:gap-6"> use App\Enums\SubscriptionPlan;
@foreach ([
[ // Name, certificate limit, price, and whether OCR is included all come
'name' => __('Gratis'), // 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.'), 'description' => __('Voor wie Certified wil proberen of maar een handvol certificaten beheert.'),
'price' => '0',
'priceNote' => __('Voor altijd gratis, geen creditcard nodig'), 'priceNote' => __('Voor altijd gratis, geen creditcard nodig'),
'featured' => false, 'featured' => false,
'cta' => __('Start gratis'), 'cta' => __('Start gratis'),
'features' => [ 'features' => [
__('Maximaal 5 certificaten'),
__('Automatische OCR-scan'),
__('Slimme e-mailreminders'), __('Slimme e-mailreminders'),
__('Veilig delen via signed URL'), __('Veilig delen via signed URL'),
], ],
], ],
[ SubscriptionPlan::Starter->value => [
'name' => 'Starter',
'description' => __("Voor wie meer dan een handvol eigen certificaten en diploma's bijhoudt."), 'description' => __("Voor wie meer dan een handvol eigen certificaten en diploma's bijhoudt."),
'price' => '12,50',
'priceNote' => __('Eén vast bedrag, jaarlijks gefactureerd'), 'priceNote' => __('Eén vast bedrag, jaarlijks gefactureerd'),
'featured' => true, 'featured' => true,
'cta' => __('Start met Starter'), 'cta' => __('Start met :plan', ['plan' => SubscriptionPlan::Starter->label()]),
'features' => [ 'features' => [
__('Maximaal 20 certificaten'),
__('Automatische OCR-scan'),
__('Slimme e-mailreminders'), __('Slimme e-mailreminders'),
__('Veilig delen via signed URL'), __('Veilig delen via signed URL'),
__('AES-256 versleutelde ZIP-export'), __('AES-256 versleutelde ZIP-export'),
], ],
], ],
[ SubscriptionPlan::Unlimited->value => [
'name' => __('Onbeperkt'),
'description' => __("Voor wie al zijn diploma's, VOG's en certificaten voorgoed op één plek wil bewaken."), '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'), 'priceNote' => __('Eén vast bedrag, jaarlijks gefactureerd'),
'featured' => false, 'featured' => false,
'cta' => __('Start met Onbeperkt'), 'cta' => __('Start met :plan', ['plan' => SubscriptionPlan::Unlimited->label()]),
'features' => [ 'features' => [
__('Onbeperkt certificaten'),
__('Automatische OCR-scan'),
__('Slimme e-mailreminders'), __('Slimme e-mailreminders'),
__('Veilig delen via signed URL'), __('Veilig delen via signed URL'),
__('AES-256 versleutelde ZIP-export'), __('AES-256 versleutelde ZIP-export'),
__('Prioriteit support'), __('Prioriteit support'),
], ],
], ],
] as $tier) ];
@endphp
<div>
<div class="grid gap-8 lg:grid-cols-3 lg:gap-6">
@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
<div @class([ <div @class([
'relative flex flex-col rounded-3xl p-8 transition-all duration-300', 'relative flex flex-col rounded-3xl p-8 transition-all duration-300',
'border-2 border-emerald-500 bg-white shadow-2xl shadow-emerald-500/15 lg:-my-4 lg:py-12 dark:bg-slate-900' => $tier['featured'], 'border-2 border-emerald-500 bg-white shadow-2xl shadow-emerald-500/15 lg:-my-4 lg:py-12 dark:bg-slate-900' => $tier['featured'],
@ -58,16 +63,37 @@
</span> </span>
@endif @endif
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">{{ $tier['name'] }}</h3> <h3 class="text-lg font-semibold text-slate-900 dark:text-white">{{ $plan->label() }}</h3>
<p class="mt-2 min-h-12 text-sm leading-relaxed text-slate-500 dark:text-slate-400">{{ $tier['description'] }}</p> <p class="mt-2 min-h-12 text-sm leading-relaxed text-slate-500 dark:text-slate-400">{{ $tier['description'] }}</p>
<div class="mt-6 flex items-baseline gap-1.5"> <div class="mt-6 flex items-baseline gap-1.5">
<span class="text-4xl font-bold tracking-tight text-slate-900 dark:text-white">&euro;{{ $tier['price'] }}</span> <span class="text-4xl font-bold tracking-tight text-slate-900 dark:text-white">&euro;{{ $price }}</span>
<span class="text-sm font-medium text-slate-500 dark:text-slate-400">/ {{ __('jaar') }}</span> <span class="text-sm font-medium text-slate-500 dark:text-slate-400">/ {{ __('jaar') }}</span>
</div> </div>
@if ($plan->isPaid())
<p class="mt-1 text-xs text-slate-400 dark:text-slate-500">
&euro;{{ number_format($exclVatEuros, 2, ',', '.') }} {{ __('excl. btw') }}
</p>
@endif
<p class="mt-1 text-xs text-slate-400 dark:text-slate-500">{{ $tier['priceNote'] }}</p> <p class="mt-1 text-xs text-slate-400 dark:text-slate-500">{{ $tier['priceNote'] }}</p>
<ul class="mt-8 flex-1 space-y-3.5 text-sm"> <ul class="mt-8 flex-1 space-y-3.5 text-sm">
<li class="flex items-start gap-3">
<svg class="mt-0.5 size-4.5 shrink-0 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
<span class="text-slate-600 dark:text-slate-300">
{{ $limit === null ? __('Onbeperkt certificaten') : __('Maximaal :count certificaten', ['count' => $limit]) }}
</span>
</li>
@if ($plan->includesOcr())
<li class="flex items-start gap-3">
<svg class="mt-0.5 size-4.5 shrink-0 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
<span class="text-slate-600 dark:text-slate-300">{{ __('Automatische OCR-scan') }}</span>
</li>
@endif
@foreach ($tier['features'] as $feature) @foreach ($tier['features'] as $feature)
<li class="flex items-start gap-3"> <li class="flex items-start gap-3">
<svg class="mt-0.5 size-4.5 shrink-0 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" aria-hidden="true"> <svg class="mt-0.5 size-4.5 shrink-0 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" aria-hidden="true">
@ -79,7 +105,7 @@
</ul> </ul>
<a <a
href="{{ route('register') }}" href="{{ auth()->check() ? route('billing.edit') : route('register') }}"
@class([ @class([
'mt-10 block rounded-xl px-4 py-3 text-center text-sm font-semibold transition-all duration-200', 'mt-10 block rounded-xl px-4 py-3 text-center text-sm font-semibold transition-all duration-200',
'bg-emerald-600 text-white shadow-lg shadow-emerald-600/25 hover:-translate-y-px hover:bg-emerald-500 hover:shadow-xl hover:shadow-emerald-500/30 active:translate-y-0' => $tier['featured'], 'bg-emerald-600 text-white shadow-lg shadow-emerald-600/25 hover:-translate-y-px hover:bg-emerald-500 hover:shadow-xl hover:shadow-emerald-500/30 active:translate-y-0' => $tier['featured'],
@ -91,6 +117,6 @@
</div> </div>
<p class="mt-10 text-center text-sm text-slate-500 dark:text-slate-400"> <p class="mt-10 text-center text-sm text-slate-500 dark:text-slate-400">
{{ __('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.') }}
</p> </p>
</div> </div>

View file

@ -1,7 +1,7 @@
@extends('marketing.layout') @extends('marketing.layout')
@section('title', 'Pricing — Certified') @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') @section('content')
@ -31,10 +31,9 @@
<section class="border-y border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-900/40" aria-label="{{ __('Inbegrepen bij elk pakket') }}"> <section class="border-y border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-900/40" aria-label="{{ __('Inbegrepen bij elk pakket') }}">
<div class="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8">
<h2 class="text-center text-2xl font-bold tracking-tight text-slate-900 dark:text-white">{{ __('Inbegrepen bij elk pakket') }}</h2> <h2 class="text-center text-2xl font-bold tracking-tight text-slate-900 dark:text-white">{{ __('Inbegrepen bij elk pakket') }}</h2>
<div class="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-4"> <div class="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
@foreach ([ @foreach ([
['title' => __('Europese hosting'), 'body' => __('Al je data in EU-datacenters, volledig AVG-proof.')], ['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' => __('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.")], ['title' => __('Veilig delen'), 'body' => __("Ondertekende links (48u) en AES-256 versleutelde ZIP's.")],
] as $item) ] as $item)
@ -55,12 +54,12 @@
'faqTitle' => __('Vragen over prijzen'), 'faqTitle' => __('Vragen over prijzen'),
'faqSubtitle' => __('Alles over facturering, opzeggen en overstappen.'), 'faqSubtitle' => __('Alles over facturering, opzeggen en overstappen.'),
'faqs' => [ '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' => __('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: €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' => __('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' => __('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' => __('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' => __('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.')],
], ],
]) ])

View file

@ -79,7 +79,7 @@
<article> <article>
<h2 class="text-xl font-bold tracking-tight text-slate-900 dark:text-white">{{ __('Artikel 5 — Abonnementen en betaling') }}</h2> <h2 class="text-xl font-bold tracking-tight text-slate-900 dark:text-white">{{ __('Artikel 5 — Abonnementen en betaling') }}</h2>
<div class="mt-3 space-y-3 text-base leading-relaxed text-slate-600 dark:text-slate-400"> <div class="mt-3 space-y-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
<p>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.') }}</p> <p>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.') }}</p>
<p>5.2 {{ __('Upgraden kan op elk moment; je betaalt dan alleen het verschil voor de resterende looptijd.') }}</p> <p>5.2 {{ __('Upgraden kan op elk moment; je betaalt dan alleen het verschil voor de resterende looptijd.') }}</p>
<p>5.3 {{ __('Downgraden of opzeggen van een betaald abonnement gaat in op de eerstvolgende verlengingsdatum. Reeds betaalde bedragen worden niet naar rato terugbetaald.') }}</p> <p>5.3 {{ __('Downgraden of opzeggen van een betaald abonnement gaat in op de eerstvolgende verlengingsdatum. Reeds betaalde bedragen worden niet naar rato terugbetaald.') }}</p>
<p>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.') }}</p> <p>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.') }}</p>

View file

@ -35,6 +35,13 @@ new #[Title('Add certificate')] class extends Component {
public bool $scanning = false; 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 * Runs when a file is selected. If OCR is enabled for the user, analyze
* the uploaded document and pre-fill empty fields with suggestions. * the uploaded document and pre-fill empty fields with suggestions.
@ -47,7 +54,7 @@ new #[Title('Add certificate')] class extends Component {
$user = Auth::user(); $user = Auth::user();
if ($user->ocr_provider === 'none') { if ($user->ocr_provider === 'none' || ! $user->plan()->includesOcr()) {
return; return;
} }
@ -70,6 +77,15 @@ new #[Title('Add certificate')] class extends Component {
public function save(): void 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([ $validated = $this->validate([
'file' => ['required', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'], 'file' => ['required', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'],
'title' => ['required', 'string', 'max:255'], 'title' => ['required', 'string', 'max:255'],
@ -138,6 +154,17 @@ new #[Title('Add certificate')] class extends Component {
<flux:text class="mt-1">{{ __('Upload a document to preview it, then confirm the details — pre-filled automatically if scanning is enabled.') }}</flux:text> <flux:text class="mt-1">{{ __('Upload a document to preview it, then confirm the details — pre-filled automatically if scanning is enabled.') }}</flux:text>
</div> </div>
@if ($limitReached)
<flux:callout icon="lock-closed" variant="warning" :heading="__('Certificate limit reached')">
<flux:callout.text>
{{ __('Your plan is full — upgrade to add more certificates.') }}
</flux:callout.text>
<x-slot name="actions">
<flux:button :href="route('marketing.pricing')" wire:navigate variant="primary">{{ __('View plans') }}</flux:button>
<flux:button :href="route('certificates.index')" wire:navigate variant="ghost">{{ __('Back to certificates') }}</flux:button>
</x-slot>
</flux:callout>
@else
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2 lg:items-start"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-2 lg:items-start">
{{-- Left: live document preview (client-side, before upload completes) --}} {{-- Left: live document preview (client-side, before upload completes) --}}
<div class="lg:sticky lg:top-6"> <div class="lg:sticky lg:top-6">
@ -204,4 +231,5 @@ new #[Title('Add certificate')] class extends Component {
</div> </div>
</form> </form>
</div> </div>
@endif
</section> </section>

View file

@ -173,13 +173,30 @@ new #[Title('Certificates')] class extends Component {
{ {
return Category::orderBy('name')->get(); return Category::orderBy('name')->get();
} }
#[Computed]
public function certificateLimit(): ?int
{
return Auth::user()->plan()->certificateLimit();
}
#[Computed]
public function certificateCount(): int
{
return Certificate::count();
}
}; ?> }; ?>
<section class="flex h-full w-full flex-1 flex-col gap-6"> <section class="flex h-full w-full flex-1 flex-col gap-6">
<div class="flex flex-wrap items-end justify-between gap-4"> <div class="flex flex-wrap items-end justify-between gap-4">
<div> <div>
<flux:heading size="xl">{{ __('Certificates') }}</flux:heading> <flux:heading size="xl">{{ __('Certificates') }}</flux:heading>
<flux:text class="mt-1">{{ __('An overview of your certificates and their expiry status.') }}</flux:text> <flux:text class="mt-1">
{{ __('An overview of your certificates and their expiry status.') }}
@if ($this->certificateLimit !== null)
&middot; {{ __(':used of :limit certificates used', ['used' => $this->certificateCount, 'limit' => $this->certificateLimit]) }}
@endif
</flux:text>
</div> </div>
<div class="flex items-end gap-3"> <div class="flex items-end gap-3">
@ -190,9 +207,17 @@ new #[Title('Certificates')] class extends Component {
@endforeach @endforeach
</flux:select> </flux:select>
@if ($this->certificateLimit !== null && $this->certificateCount >= $this->certificateLimit)
<flux:tooltip :content="__('Upgrade your plan to add more certificates.')" position="bottom">
<flux:button :href="route('marketing.pricing')" wire:navigate icon="lock-closed" variant="primary">
{{ __('Add certificate') }}
</flux:button>
</flux:tooltip>
@else
<flux:button :href="route('certificates.create')" wire:navigate icon="plus" variant="primary"> <flux:button :href="route('certificates.create')" wire:navigate icon="plus" variant="primary">
{{ __('Add certificate') }} {{ __('Add certificate') }}
</flux:button> </flux:button>
@endif
</div> </div>
</div> </div>

View file

@ -5,6 +5,7 @@
<flux:navlist.item :href="route('security.edit')" wire:navigate>{{ __('Security') }}</flux:navlist.item> <flux:navlist.item :href="route('security.edit')" wire:navigate>{{ __('Security') }}</flux:navlist.item>
<flux:navlist.item :href="route('appearance.edit')" wire:navigate>{{ __('Appearance') }}</flux:navlist.item> <flux:navlist.item :href="route('appearance.edit')" wire:navigate>{{ __('Appearance') }}</flux:navlist.item>
<flux:navlist.item :href="route('certificate-settings.edit')" wire:navigate>{{ __('Certificates') }}</flux:navlist.item> <flux:navlist.item :href="route('certificate-settings.edit')" wire:navigate>{{ __('Certificates') }}</flux:navlist.item>
<flux:navlist.item :href="route('billing.edit')" wire:navigate>{{ __('Billing') }}</flux:navlist.item>
</flux:navlist> </flux:navlist>
</div> </div>

View file

@ -0,0 +1,206 @@
<?php
use App\Enums\SubscriptionPlan;
use App\Enums\SubscriptionStatus;
use App\Services\Billing\SubscriptionManager;
use Flux\Flux;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Title;
use Livewire\Component;
new #[Title('Billing')] class extends Component
{
public function choosePlan(string $plan): void
{
$validated = validator(['plan' => $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<int, SubscriptionPlan>
*/
#[Computed]
public function plans(): array
{
return SubscriptionPlan::cases();
}
}; ?>
<section class="w-full">
@include('partials.settings-heading')
<x-pages::settings.layout :heading="__('Billing')" :subheading="__('Manage your Certified subscription and invoices')">
<div class="my-6 w-full max-w-none space-y-8">
{{-- Current status --}}
<div class="rounded-2xl border border-slate-200 p-6 dark:border-slate-800">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<flux:heading size="lg">{{ $this->subscription->plan->label() }}</flux:heading>
<flux:badge size="sm" :color="$this->subscription->status->color()">{{ $this->subscription->status->label() }}</flux:badge>
</div>
@php $limit = $this->subscription->plan->certificateLimit(); @endphp
@if ($limit !== null)
<flux:text class="mt-1">
{{ __(':used of :limit certificates used', ['used' => $this->usageCount, 'limit' => $limit]) }}
</flux:text>
@endif
</div>
@if ($this->subscription->plan->isPaid() && $this->subscription->current_period_ends_at)
<div class="text-right">
@if ($this->subscription->cancel_at_period_end)
<flux:text class="text-amber-600 dark:text-amber-400">
{{ __('Ends on :date', ['date' => $this->subscription->current_period_ends_at->toFormattedDateString()]) }}
</flux:text>
<flux:button size="sm" variant="ghost" wire:click="resume">{{ __('Undo cancellation') }}</flux:button>
@else
<flux:text>{{ __('Renews on :date', ['date' => $this->subscription->current_period_ends_at->toFormattedDateString()]) }}</flux:text>
<flux:button size="sm" variant="ghost" wire:click="cancel" wire:confirm="{{ __('Are you sure you want to cancel? Your subscription stays active until the end of the current period.') }}">
{{ __('Cancel subscription') }}
</flux:button>
@endif
</div>
@endif
</div>
@if ($this->subscription->status === SubscriptionStatus::PastDue && $this->subscription->grace_ends_at)
<flux:callout icon="exclamation-triangle" variant="danger" class="mt-4">
{{ __("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()]) }}
</flux:callout>
@endif
</div>
{{-- Plan switcher --}}
<div>
<flux:heading size="lg">{{ __('Change plan') }}</flux:heading>
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-3">
@foreach ($this->plans as $plan)
@php $isCurrent = $this->subscription->plan === $plan; @endphp
<div @class([
'flex flex-col gap-2 rounded-2xl border p-5',
'border-emerald-500 ring-1 ring-emerald-500' => $isCurrent,
'border-slate-200 dark:border-slate-800' => ! $isCurrent,
])>
<flux:heading>{{ $plan->label() }}</flux:heading>
<flux:text>
@php $planLimit = $plan->certificateLimit(); @endphp
{{ $planLimit === null ? __('Unlimited certificates') : __('Up to :count certificates', ['count' => $planLimit]) }}
</flux:text>
<flux:text class="text-2xl font-bold text-slate-900 dark:text-white">
&euro;{{ number_format($plan->priceInclVatCents() / 100, 2, ',', '.') }}<span class="text-sm font-normal text-slate-500">/{{ __('year') }}</span>
</flux:text>
@if ($plan->isPaid())
<flux:text class="text-xs text-slate-400">
&euro;{{ number_format($plan->priceInCents() / 100, 2, ',', '.') }} {{ __('excl. VAT') }}
</flux:text>
@endif
@if ($isCurrent)
<flux:button disabled class="mt-2">{{ __('Current plan') }}</flux:button>
@else
<flux:button
class="mt-2"
variant="{{ $plan->priceInCents() > $this->subscription->plan->priceInCents() ? 'primary' : 'ghost' }}"
wire:click="choosePlan('{{ $plan->value }}')"
>
{{ $plan->priceInCents() > $this->subscription->plan->priceInCents() ? __('Upgrade') : __('Switch') }}
</flux:button>
@endif
</div>
@endforeach
</div>
</div>
{{-- Invoice history --}}
@if ($this->invoices->isNotEmpty())
<div>
<flux:heading size="lg">{{ __('Invoices') }}</flux:heading>
<flux:table class="mt-4">
<flux:table.columns>
<flux:table.column>{{ __('Date') }}</flux:table.column>
<flux:table.column>{{ __('Plan') }}</flux:table.column>
<flux:table.column>{{ __('Amount') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column></flux:table.column>
</flux:table.columns>
<flux:table.rows>
@foreach ($this->invoices as $invoice)
<flux:table.row>
<flux:table.cell>{{ $invoice->invoice_date?->toFormattedDateString() }}</flux:table.cell>
<flux:table.cell>{{ $invoice->plan->label() }}</flux:table.cell>
<flux:table.cell>&euro;{{ number_format($invoice->amount_cents / 100, 2, ',', '.') }}</flux:table.cell>
<flux:table.cell>{{ $invoice->state }}</flux:table.cell>
<flux:table.cell>
@if ($invoice->state !== 'paid' && $invoice->payment_url)
<flux:link :href="$invoice->payment_url" target="_blank">{{ __('Pay') }}</flux:link>
@endif
</flux:table.cell>
</flux:table.row>
@endforeach
</flux:table.rows>
</flux:table>
</div>
@endif
</div>
</x-pages::settings.layout>
</section>

View file

@ -7,7 +7,8 @@ use Illuminate\Validation\Rule;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Livewire\Component; use Livewire\Component;
new #[Title('Certificate settings')] class extends Component { new #[Title('Certificate settings')] class extends Component
{
public int $reminder_months_before = 1; public int $reminder_months_before = 1;
public string $ocr_provider = 'none'; public string $ocr_provider = 'none';
@ -16,6 +17,8 @@ new #[Title('Certificate settings')] class extends Component {
public bool $hasApiKey = false; public bool $hasApiKey = false;
public bool $ocrAvailable = false;
public function mount(): void public function mount(): void
{ {
$user = Auth::user(); $user = Auth::user();
@ -23,13 +26,22 @@ new #[Title('Certificate settings')] class extends Component {
$this->reminder_months_before = $user->reminder_months_before; $this->reminder_months_before = $user->reminder_months_before;
$this->ocr_provider = $user->ocr_provider; $this->ocr_provider = $user->ocr_provider;
$this->hasApiKey = filled($user->ocr_api_key); $this->hasApiKey = filled($user->ocr_api_key);
$this->ocrAvailable = $user->plan()->includesOcr();
}
/**
* @return array<int, string>
*/
private function allowedOcrProviders(): array
{
return $this->ocrAvailable ? OcrManager::availableProviders() : ['none'];
} }
public function updateSettings(): void public function updateSettings(): void
{ {
$validated = $this->validate([ $validated = $this->validate([
'reminder_months_before' => ['required', 'integer', 'min:0', 'max:24'], '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'], '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')" :label="__('Remind me this many months before expiry')"
/> />
<flux:select wire:model.live="ocr_provider" :label="__('Document scanning (OCR)')"> <flux:select wire:model.live="ocr_provider" :label="__('Document scanning (OCR)')" :disabled="! $ocrAvailable">
<flux:select.option value="none">{{ __('None (manual entry)') }}</flux:select.option> <flux:select.option value="none">{{ __('None (manual entry)') }}</flux:select.option>
@if ($ocrAvailable)
<flux:select.option value="klippa">{{ __('Klippa (EU / NL)') }}</flux:select.option> <flux:select.option value="klippa">{{ __('Klippa (EU / NL)') }}</flux:select.option>
<flux:select.option value="mistral">{{ __('Mistral Pixtral (EU / FR)') }}</flux:select.option> <flux:select.option value="mistral">{{ __('Mistral Pixtral (EU / FR)') }}</flux:select.option>
@endif
</flux:select> </flux:select>
@unless ($ocrAvailable)
<flux:callout icon="lock-closed" variant="secondary">
<flux:callout.text>
{{ __('Document scanning is available on Certified Starter and Certified Pro.') }}
<flux:callout.link :href="route('marketing.pricing')">{{ __('Upgrade your plan') }}</flux:callout.link>
</flux:callout.text>
</flux:callout>
@endunless
@if ($ocr_provider !== 'none') @if ($ocr_provider !== 'none')
<flux:input <flux:input
type="password" type="password"

View file

@ -9,3 +9,4 @@ Artisan::command('inspire', function () {
})->purpose('Display an inspiring quote'); })->purpose('Display an inspiring quote');
Schedule::command('app:send-certificate-reminders')->dailyAt('08:00'); Schedule::command('app:send-certificate-reminders')->dailyAt('08:00');
Schedule::command('app:sync-subscriptions')->dailyAt('07:30');

View file

@ -13,6 +13,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::livewire('settings/certificates', 'pages::settings.certificates')->name('certificate-settings.edit'); 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') Route::livewire('settings/security', 'pages::settings.security')
->middleware([ ->middleware([
'password.confirm', 'password.confirm',

View file

@ -4,6 +4,7 @@ use App\Http\Controllers\CertificateDownloadController;
use App\Http\Controllers\CertificatePreviewController; use App\Http\Controllers\CertificatePreviewController;
use App\Http\Controllers\LanguageController; use App\Http\Controllers\LanguageController;
use App\Http\Controllers\MarketingController; use App\Http\Controllers\MarketingController;
use App\Http\Controllers\MoneybirdWebhookController;
use App\Http\Controllers\SharePageController; use App\Http\Controllers\SharePageController;
use App\Http\Controllers\VerifyController; use App\Http\Controllers\VerifyController;
use Illuminate\Support\Facades\Route; 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::get('verify', [VerifyController::class, 'show'])->name('verify.show');
Route::post('verify', [VerifyController::class, 'check'])->middleware('throttle:10,1')->name('verify.check'); 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::middleware(['auth', 'verified'])->group(function () {
Route::livewire('dashboard', 'pages::dashboard')->name('dashboard'); Route::livewire('dashboard', 'pages::dashboard')->name('dashboard');

View file

@ -0,0 +1,91 @@
<?php
use App\Enums\SubscriptionPlan;
use App\Enums\SubscriptionStatus;
use App\Models\Subscription;
use App\Models\SubscriptionInvoice;
use App\Models\User;
use App\Notifications\SubscriptionActivatedNotification;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Notification;
beforeEach(function () {
config([
'services.moneybird.api_token' => '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);
});

View file

@ -0,0 +1,137 @@
<?php
use App\Enums\SubscriptionPlan;
use App\Enums\SubscriptionStatus;
use App\Models\Subscription;
use App\Models\SubscriptionInvoice;
use App\Models\User;
use App\Services\Billing\SubscriptionManager;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
config([
'services.moneybird.api_token' => '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');
});

View file

@ -0,0 +1,92 @@
<?php
use App\Enums\SubscriptionPlan;
use App\Enums\SubscriptionStatus;
use App\Models\Subscription;
use App\Models\SubscriptionInvoice;
use App\Models\User;
use App\Notifications\SubscriptionDowngradedNotification;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Notification;
beforeEach(function () {
config([
'services.moneybird.api_token' => '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();
});

View file

@ -115,3 +115,26 @@ test('expired certificates show an expired badge', function () {
$this->get(route('certificates.index'))->assertSee('Expired'); $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);
});

View file

@ -1,5 +1,7 @@
<?php <?php
use App\Models\Certificate;
use App\Models\Subscription;
use App\Models\User; use App\Models\User;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
@ -90,6 +92,7 @@ test('ocr suggestions pre-fill empty fields on upload', function () {
]); ]);
$user = User::factory()->create(); $user = User::factory()->create();
Subscription::factory()->for($user)->starter()->create();
$user->ocr_provider = 'klippa'; $user->ocr_provider = 'klippa';
$user->ocr_api_key = 'key'; $user->ocr_api_key = 'key';
$user->save(); $user->save();
@ -102,6 +105,29 @@ test('ocr suggestions pre-fill empty fields on upload', function () {
->assertSet('expires_at', '2028-01-15'); ->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 () { test('ocr does not overwrite fields the user already filled', function () {
Http::fake([ Http::fake([
'custom-ocr.klippa.com/*' => Http::response([ '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(); $user = User::factory()->create();
Subscription::factory()->for($user)->starter()->create();
$user->ocr_provider = 'klippa'; $user->ocr_provider = 'klippa';
$user->ocr_api_key = 'key'; $user->ocr_api_key = 'key';
$user->save(); $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')) ->set('file', UploadedFile::fake()->create('cert.pdf', 200, 'application/pdf'))
->assertSet('title', 'My Own Title'); ->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);
});

View file

@ -1,5 +1,6 @@
<?php <?php
use App\Models\Subscription;
use App\Models\User; use App\Models\User;
use Livewire\Livewire; use Livewire\Livewire;
@ -9,8 +10,9 @@ test('the certificate settings page is displayed', function () {
$this->get(route('certificate-settings.edit'))->assertOk(); $this->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(); $user = User::factory()->create();
Subscription::factory()->for($user)->starter()->create();
$this->actingAs($user); $this->actingAs($user);
Livewire::test('pages::settings.certificates') 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 () { test('a blank api key keeps the existing one', function () {
$user = User::factory()->create(); $user = User::factory()->create();
Subscription::factory()->for($user)->starter()->create();
$user->ocr_provider = 'klippa'; $user->ocr_provider = 'klippa';
$user->ocr_api_key = 'original-key'; $user->ocr_api_key = 'original-key';
$user->save(); $user->save();
@ -46,10 +49,36 @@ test('a blank api key keeps the existing one', function () {
}); });
test('an unknown ocr provider is rejected', 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') Livewire::test('pages::settings.certificates')
->set('ocr_provider', 'hacker') ->set('ocr_provider', 'hacker')
->call('updateSettings') ->call('updateSettings')
->assertHasErrors(['ocr_provider']); ->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');
});