feat(billing): renewals, coupons, VAT numbering, billing cycles and invoice payment tracking

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-19 16:37:56 +07:00
co-authored by Claude Sonnet 5.5
parent ea9a53f742
commit 25f23d541e
134 changed files with 4926 additions and 185 deletions
@@ -173,4 +173,86 @@ class ServiceExpiryTest extends TestCase
$invoice = Invoice::find($service->refresh()->invoice_id);
$this->assertSame(90_000, (int) $invoice->total);
}
public function test_auto_renew_service_is_paid_from_balance_and_extended(): void
{
$service = $this->createExpiringService(90_000);
$service->update(['auto_renew' => true]);
$service->customer->update(['total' => 200_000]);
$oldExpiry = $service->expires_at->copy();
$this->artisan('service:expiry')->assertExitCode(0);
$service->refresh();
$this->assertSame(InvoiceStatus::PAID->value, Invoice::find($service->invoice_id)->status);
$this->assertTrue($service->expires_at->greaterThan($oldExpiry));
$this->assertSame(110_000, (int) $service->customer->fresh()->total);
$log = \App\Models\ActivityLog::where('subject_id', $service->id)->where('event', 'renewed')->first();
$this->assertSame('system', $log->causer_type);
$this->assertSame('balance', $log->new_values['payment_method']);
}
public function test_auto_renew_with_insufficient_balance_keeps_invoice_unpaid_and_retries_after_topup(): void
{
$service = $this->createExpiringService(90_000);
$service->update(['auto_renew' => true]);
$service->customer->update(['total' => 10_000]);
$this->artisan('service:expiry')->assertExitCode(0);
$this->assertSame(InvoiceStatus::UNPAID->value, Invoice::find($service->refresh()->invoice_id)->status);
$this->assertSame(10_000, (int) $service->customer->fresh()->total);
$service->customer->update(['total' => 100_000]);
$this->artisan('service:expiry')->assertExitCode(0);
$this->assertSame(InvoiceStatus::PAID->value, Invoice::find($service->invoice_id)->status);
}
public function test_service_without_auto_renew_is_not_charged(): void
{
$service = $this->createExpiringService(90_000);
$service->customer->update(['total' => 200_000]);
$this->artisan('service:expiry')->assertExitCode(0);
$this->assertSame(InvoiceStatus::UNPAID->value, Invoice::find($service->refresh()->invoice_id)->status);
$this->assertSame(200_000, (int) $service->customer->fresh()->total);
}
public function test_renewal_invoice_creates_customer_notification_and_queues_email(): void
{
\Illuminate\Support\Facades\Queue::fake();
\Illuminate\Support\Facades\DB::table('setting')->updateOrInsert(['key' => 'email_settings'], ['value' => json_encode(['auto_disabled' => []])]);
$service = $this->createExpiringService(90_000);
$this->artisan('service:expiry')->assertExitCode(0);
$invoice = Invoice::find($service->refresh()->invoice_id);
$notification = \App\Models\Notification::where('recipient_type', 'client')
->where('recipient_id', $service->customer_id)->first();
$this->assertNotNull($notification);
$this->assertSame('renewal_invoice', $notification->type);
$this->assertStringContainsString($invoice->invoice_number, $notification->body);
$this->assertStringContainsString('?invoice=' . $invoice->invoice_number, $notification->link);
\Illuminate\Support\Facades\Queue::assertPushed(
\App\Jobs\Mail\SendInvoiceMailJob::class,
fn ($job) => $job->invoiceId === $invoice->id && $job->type === 'renewal',
);
$this->artisan('service:expiry')->assertExitCode(0);
$this->assertSame(1, \App\Models\Notification::where('recipient_id', $service->customer_id)->count());
}
public function test_email_is_skipped_when_invoice_notification_is_auto_disabled(): void
{
\Illuminate\Support\Facades\Queue::fake();
\Illuminate\Support\Facades\DB::table('setting')->updateOrInsert(['key' => 'email_settings'], ['value' => json_encode(['auto_disabled' => ['invoice_notification']])]);
$service = $this->createExpiringService(90_000);
$this->artisan('service:expiry')->assertExitCode(0);
$this->assertSame(1, \App\Models\Notification::where('recipient_id', $service->customer_id)->count());
\Illuminate\Support\Facades\Queue::assertNotPushed(\App\Jobs\Mail\SendInvoiceMailJob::class);
}
}