Kita telah menempuh materi Laravel dari routing, Eloquent ORM, validasi, Blade, sampai testing, dan proyek ini menjadi tempat seluruh materi tersebut dipakai membangun satu aplikasi utuh: aplikasi pengelolaan keuangan pribadi yang mencatat pemasukan dan pengeluaran harian, mengelompokkannya ke dalam kategori berwarna, lalu menghasilkan laporan bulanan lengkap dengan grafik. Aplikasi keuangan dipilih karena masalahnya berlapis: mencatat transaksi adalah soal CRUD, tetapi menjawab “berapa total pengeluaran makan bulan ini?” adalah soal agregasi SQL — dan angka uang tidak memaafkan kesalahan hitung. Sepanjang proyek, setiap angka yang tampil di layar bisa dilacak balik ke baris transaksi yang menopangnya; itulah standar kebenaran yang kita pegang.
Aplikasi yang kita bangun bekerja sebagai aplikasi web dengan login:
- mencatat transaksi pemasukan dan pengeluaran lewat CRUD lengkap dengan validasi nominal, tanggal, dan konsistensi kategori,
- mengelola kategori pemasukan/pengeluaran dengan nama unik dan warna penanda berkode hex,
- menolak nominal pecahan atau nol, tanggal masa depan, serta kombinasi kategori yang tidak cocok dengan jenis transaksi,
- memfilter daftar transaksi per bulan dan membatasi setiap pengguna hanya pada transaksinya sendiri,
- menyajikan laporan bulanan: total pemasukan, pengeluaran, selisih, rincian per kategori, dan tren arus kas enam bulan,
- menggambar grafik batang murni dengan CSS — tanpa satu pun package JavaScript — sehingga hasilnya deterministik dan bisa diuji.
Batas scope juga ditetapkan sejak awal: aplikasi ini pencatat dan pelapor, bukan perencana anggaran. Fitur seperti target anggaran bulanan, ekspor PDF, atau multi-mata-uang sengaja tidak dibangun, tetapi fondasi yang kita pasang — relasi bersih, service terpusat, test yang menjaga — membuat fitur-fitur itu cukup ditambahkan tanpa membongkar yang lama.
Tujuan Pembelajaran #
Setelah menyelesaikan proyek ini, kita mampu:
- menerjemahkan kebutuhan pencatatan keuangan menjadi dua tabel yang berelasi dengan foreign key dan index yang disiapkan untuk query laporan,
- mengambil keputusan desain yang harus dijelaskan: menyimpan kolom
typepada transaksi sendiri atau menurunkannya dari kategori, - menulis validasi lintas-kolom lewat
Rule::exists()dengan klausawhereuntuk menjaga konsistensi jenis transaksi, - memusatkan seluruh agregasi dalam satu service class dengan query builder
selectRaw()dangroupBy(), - merancang grafik batang dari persentase lebar
divsehingga bisa diperiksa oleh feature test, - membuktikan kebenaran hitungan laporan dengan test yang menuntaskan angka secara manual sebelum dibandingkan.
Prasyarat #
Proyek ini tidak memperkenalkan materi baru, melainkan memakai materi yang sudah dibahas. Chapter yang paling sering dirujuk:
- Membuat Migration , Foreign Keys , dan Indexes dan Constraints — membangun dua tabel inti,
- Konsep Eloquent
dan One to Many
— relasi
User-Transaction-Category, - Casts dan Attribute serta Query Scopes — tipe kolom dan filter bulan,
- Resource Controller dan Form Request Classes — CRUD transaksi dan validasinya,
- Validasi Unique dan Exists — aturan nama kategori unik dan konsistensi jenis,
- Middleware untuk Autentikasi — gerbang login seluruh fitur,
- Database Testing dan Feature Tests — pengujian alur dan hitungan laporan.
Struktur Data #
Dua tabel baru menjadi inti aplikasi: categories menyimpan daftar kategori (name, type income/expense, color kode hex), sedangkan transactions menyimpan setiap catatan uang (user_id, category_id, amount, type, transaction_date, note). Setiap transaksi dimiliki satu pengguna dan diberi label satu kategori — relasi hasMany dan belongsTo yang sudah akrab bagi kita, tetapi kali ini dengan keputusan desain yang menarik: kolom jenis transaksi disimpan ganda, di kategori maupun di transaksi, dengan validasi yang menjaga keduanya tidak pernah bertentangan. Diagram ER beserta alasan setiap kolom dibedah tuntas pada Bagian 1, dan seluruh berkas sumber tersedia pada folder proyek ini: app/, database/, resources/, routes/, dan tests/.
Sebagai gambaran hasil akhir, peta enam bagian beserta keluaran utamanya tampak seperti ini:
| Bagian | Keluaran utama |
|---|---|
| 1. Perancangan | dua migration dengan foreign key dan index |
| 2. Pencatatan Transaksi | resource CRUD + TransactionRequest |
| 3. Kategori | CRUD kategori + aturan perlindungan |
| 4. Laporan | ReportService dengan tiga agregasi |
| 5. Visualisasi | grafik batang CSS pada halaman laporan |
| 6. Pengujian | 28 test (feature + unit) yang selalu hijau |
Tahapan Pengerjaan #
Proyek dikerjakan dalam enam bagian berurutan. Setiap bagian menghasilkan aplikasi yang dapat dijalankan penuh:
- Bagian 1: Perancangan Aplikasi — memetakan entitas, memutuskan lokasi kolom jenis transaksi, dan membangun migrasi dua tabel,
- Bagian 2: Pencatatan Transaksi — membangun CRUD transaksi dengan validasi nominal, tanggal, dan konsistensi kategori,
- Bagian 3: Kategori Pengeluaran — mengelola kategori, warna penanda, dan aturan perlindungan kategori yang sedang dipakai,
- Bagian 4: Laporan Keuangan — memusatkan agregasi bulanan dan per kategori dalam
ReportService, - Bagian 5: Visualisasi Data — menggambar grafik batang CSS dari data agregasi,
- Bagian 6: Pengujian Aplikasi — membuktikan seluruh alur dan hitungan lewat feature test.
Mulailah dari Bagian 1 dan ikuti urutannya karena setiap bagian dibangun di atas berkas bagian sebelumnya: agregasi Bagian 4 ditopang index yang dibangun di Bagian 1, dan test Bagian 6 menuntaskan angka yang pertama kali muncul pada seeder. Bila terjebak di tengah jalan, bandingkan hasil Anda dengan berkas sumber pada folder proyek ini — struktur foldernya mengikuti struktur aplikasi Laravel standar sehingga mudah dipetakan satu per satu.
Menjalankan Aplikasi #
Setelah mengikuti seluruh bagian (atau menyalin berkas sumber ke proyek Laravel baru dengan database SQLite), siapkan database beserta data contoh:
php artisan migrate --seedOutput yang Dihasilkan:
INFO Preparing database.
Creating migration table ....................................... DONE
INFO Running migrations.
0001_01_01_000000_create_users_table ........................... DONE
0001_01_01_000001_create_cache_table ........................... DONE
0001_01_01_000002_create_jobs_table ............................ DONE
2026_10_13_153049_create_personal_access_tokens_table .......... DONE
2026_11_01_000010_create_categories_table ...................... DONE
2026_11_01_000020_create_transactions_table .................... DONE
INFO Seeding database.
Transaksi terisi: 11Seeder membuat satu akun percobaan — email [email protected] dengan password password — beserta lima kategori dan sebelas transaksi pada Agustus sampai Oktober 2026. Jalankan php artisan serve, buka http://localhost:8000, masuk dengan akun tersebut, lalu buka menu Laporan untuk melihat ringkasan Oktober 2026 lengkap dengan grafik batangnya. Sebelum menjelajah, ada baiknya memastikan seluruh aturan aplikasi masih utuh dengan menjalankan php artisan test — 28 test dengan 103 assertion seharusnya berakhir hijau dalam waktu kurang dari satu detik, persis seperti output yang kita bedah pada Bagian 6.
Rancang entitas transaksi dan kategori beserta aturan pemasukan-pengeluaran.
Buka → # Bagian 2: Pencatatan TransaksiBangun CRUD transaksi dengan validasi nominal dan tanggal.
Buka → # Bagian 3: Kategori PengeluaranKelola kategori beserta relasi dan warna penanda.
Buka → # Bagian 4: Laporan KeuanganAgregasi total per periode dan kategori dengan query builder.
Buka → # Bagian 5: Visualisasi DataSajikan grafik batang sederhana dari data agregasi.
Buka → # Bagian 6: Pengujian AplikasiUji alur pencatatan dan perhitungan laporan.
Buka →Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
app/Http/Controllers/AuthController.php
Unduh berkas<?php
namespace App\Http\Controllers;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class AuthController extends Controller
{
public function showLogin(): View
{
return view('auth.login');
}
public function login(Request $request): RedirectResponse
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
if (Auth::attempt($credentials)) {
$request->session()->regenerate();
return redirect()->intended(route('transactions.index'));
}
return back()
->withErrors(['email' => 'Email atau password salah.'])
->onlyInput('email');
}
public function logout(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
}
app/Http/Controllers/CategoryController.php
Unduh berkas<?php
namespace App\Http\Controllers;
use App\Http\Requests\CategoryRequest;
use App\Models\Category;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
class CategoryController extends Controller
{
public function index(): View
{
$categories = Category::query()
->withCount('transactions')
->orderBy('type')
->orderBy('name')
->get();
return view('categories.index', [
'categories' => $categories,
]);
}
public function create(): View
{
return view('categories.create', [
'category' => new Category([
'type' => old('type', 'expense'),
'color' => old('color', '#64748b'),
]),
]);
}
public function store(CategoryRequest $request): RedirectResponse
{
Category::create($request->validated());
return redirect()
->route('categories.index')
->with('status', 'Kategori berhasil ditambahkan.');
}
public function edit(Category $category): View
{
return view('categories.edit', [
'category' => $category,
]);
}
public function update(CategoryRequest $request, Category $category): RedirectResponse
{
// Jenis kategori tidak boleh berubah setelah dipakai transaksi,
// karena setiap transaksi menyimpan salinan jenisnya sendiri.
if ($category->transactions()->exists()
&& $request->validated('type') !== $category->type) {
return back()
->withErrors(['type' => 'Jenis kategori tidak dapat diubah karena sudah dipakai transaksi.'])
->withInput();
}
$category->update($request->validated());
return redirect()
->route('categories.index')
->with('status', 'Kategori berhasil diubah.');
}
public function destroy(Category $category): RedirectResponse
{
if ($category->transactions()->exists()) {
return redirect()
->route('categories.index')
->withErrors(['name' => 'Kategori masih dipakai transaksi dan tidak dapat dihapus.']);
}
$category->delete();
return redirect()
->route('categories.index')
->with('status', 'Kategori berhasil dihapus.');
}
}
app/Http/Controllers/Controller.php
Unduh berkas<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
app/Http/Controllers/ReportController.php
Unduh berkas<?php
namespace App\Http\Controllers;
use App\Models\Transaction;
use App\Services\ReportService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
class ReportController extends Controller
{
public function __construct(private ReportService $reports)
{
}
public function show(Request $request, string $month): View
{
if (! preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month)) {
abort(404);
}
Carbon::setLocale('id');
$user = $request->user();
return view('reports.show', [
'month' => $month,
'title' => Carbon::parse($month . '-01')->translatedFormat('F Y'),
'summary' => $this->reports->monthlySummary($user, $month),
'expensesByCategory' => $this->reports->byCategory($user, $month, Transaction::TYPE_EXPENSE),
'trend' => $this->reports->monthlyTrend($user, 6, $month),
'previous' => Carbon::parse($month . '-01')->subMonth()->format('Y-m'),
'next' => Carbon::parse($month . '-01')->addMonth()->format('Y-m'),
]);
}
/**
* Laporan bulan berjalan: cukup teruskan ke /reports/{bulan-ini}.
*/
public function current(): RedirectResponse
{
return redirect()->route('reports.show', now()->format('Y-m'));
}
}
app/Http/Controllers/TransactionController.php
Unduh berkas<?php
namespace App\Http\Controllers;
use App\Http\Requests\TransactionRequest;
use App\Models\Category;
use App\Models\Transaction;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class TransactionController extends Controller
{
public function index(Request $request): View
{
// String kosong (filter dikosongkan) diperlakukan tanpa filter.
$month = $request->input('month') ?: null;
if ($month !== null && ! preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month)) {
abort(404);
}
$transactions = Transaction::query()
->with('category')
->where('user_id', $request->user()->id)
->when($month, fn ($query) => $query->inMonth($month))
->orderByDesc('transaction_date')
->orderByDesc('id')
->paginate(10)
->withQueryString();
return view('transactions.index', [
'transactions' => $transactions,
'month' => $month,
]);
}
public function create(Request $request): View
{
return view('transactions.create', [
'transaction' => new Transaction(),
'categories' => $this->categoriesForForm(),
'type' => old('type', 'expense'),
]);
}
public function store(TransactionRequest $request): RedirectResponse
{
$request->user()->transactions()->create($request->validated());
return redirect()
->route('transactions.index')
->with('status', 'Transaksi berhasil dicatat.');
}
public function edit(Request $request, Transaction $transaction): View
{
$this->authorizeOwner($request, $transaction);
return view('transactions.edit', [
'transaction' => $transaction,
'categories' => $this->categoriesForForm(),
'type' => old('type', $transaction->type),
]);
}
public function update(TransactionRequest $request, Transaction $transaction): RedirectResponse
{
$this->authorizeOwner($request, $transaction);
$transaction->update($request->validated());
return redirect()
->route('transactions.index')
->with('status', 'Transaksi berhasil diubah.');
}
public function destroy(Request $request, Transaction $transaction): RedirectResponse
{
$this->authorizeOwner($request, $transaction);
$transaction->delete();
return redirect()
->route('transactions.index')
->with('status', 'Transaksi berhasil dihapus.');
}
/**
* Kategori dikelompokkan per jenis untuk select ber-optgroup.
*
* @return array<string, \Illuminate\Database\Eloquent\Collection<int, Category>>
*/
private function categoriesForForm(): array
{
return [
'income' => Category::query()->ofType(Category::TYPE_INCOME)->orderBy('name')->get(),
'expense' => Category::query()->ofType(Category::TYPE_EXPENSE)->orderBy('name')->get(),
];
}
private function authorizeOwner(Request $request, Transaction $transaction): void
{
abort_if($transaction->user_id !== $request->user()->id, 403);
}
}
app/Http/Requests/CategoryRequest.php
Unduh berkas<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CategoryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* Dipakai untuk store dan update; saat update, nama kategori
* sendiri dikecualikan dari aturan unique.
*
* @return array<string, array<int, string|Rule>>
*/
public function rules(): array
{
$unique = Rule::unique('categories', 'name');
if ($category = $this->route('category')) {
$unique->ignore($category);
}
return [
'name' => ['required', 'string', 'max:60', $unique],
'type' => ['required', 'in:income,expense'],
'color' => ['required', 'regex:/^#[0-9a-fA-F]{6}$/'],
];
}
/**
* @return array<string, string>
*/
public function attributes(): array
{
return [
'name' => 'nama kategori',
'type' => 'jenis kategori',
'color' => 'warna',
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'color.regex' => 'Warna harus berupa kode hex 6 digit, contoh: #ef4444.',
];
}
}
app/Http/Requests/TransactionRequest.php
Unduh berkas<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class TransactionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* Aturan store dan update identik, jadi satu class cukup.
*
* @return array<string, array<int, string|Rule>>
*/
public function rules(): array
{
return [
'category_id' => [
'required',
'integer',
// Kategori harus tersedia DAN jenisnya cocok dengan
// jenis transaksi yang dipilih (income/expense).
Rule::exists('categories', 'id')->where('type', $this->input('type')),
],
'type' => ['required', 'in:income,expense'],
'amount' => ['required', 'integer', 'min:1', 'max:999999999999'],
'transaction_date' => [
'required',
'date',
'after:2000-01-01',
'before_or_equal:today',
],
'note' => ['nullable', 'string', 'max:255'],
];
}
/**
* @return array<string, string>
*/
public function attributes(): array
{
return [
'category_id' => 'kategori',
'type' => 'jenis transaksi',
'amount' => 'nominal',
'transaction_date' => 'tanggal transaksi',
'note' => 'catatan',
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'category_id.exists' => 'Kategori tidak ditemukan atau tidak cocok dengan jenis transaksi.',
];
}
}
app/Models/Category.php
Unduh berkas<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Category extends Model
{
public const TYPE_INCOME = 'income';
public const TYPE_EXPENSE = 'expense';
protected $fillable = [
'name',
'type',
'color',
];
protected function casts(): array
{
return [
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
public function transactions(): HasMany
{
return $this->hasMany(Transaction::class);
}
/**
* Ambil kategori untuk satu jenis transaksi saja (income/expense).
*/
public function scopeOfType(Builder $query, string $type): Builder
{
return $query->where('type', $type);
}
}
app/Models/Transaction.php
Unduh berkas<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
class Transaction extends Model
{
public const TYPE_INCOME = 'income';
public const TYPE_EXPENSE = 'expense';
protected $fillable = [
'category_id',
'type',
'amount',
'transaction_date',
'note',
];
protected function casts(): array
{
return [
'category_id' => 'integer',
'user_id' => 'integer',
'amount' => 'integer',
'transaction_date' => 'date:Y-m-d',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
/**
* Batasi query pada satu bulan (format Y-m, contoh: 2026-10).
*/
public function scopeInMonth(Builder $query, string $month): Builder
{
$start = Carbon::parse($month . '-01');
return $query->whereBetween('transaction_date', [
$start->toDateString(),
$start->copy()->endOfMonth()->toDateString(),
]);
}
}
app/Models/User.php
Unduh berkas<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function transactions(): HasMany
{
return $this->hasMany(Transaction::class);
}
}
app/Services/ReportService.php
Unduh berkas<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class ReportService
{
/**
* Ringkasan satu bulan: total pemasukan, pengeluaran, dan selisihnya.
*
* @return array{income: int, expense: int, balance: int}
*/
public function monthlySummary(User $user, string $month): array
{
$start = Carbon::parse($month . '-01');
$row = DB::table('transactions')
->where('user_id', $user->id)
->whereBetween('transaction_date', [
$start->toDateString(),
$start->copy()->endOfMonth()->toDateString(),
])
->selectRaw("COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) AS income")
->selectRaw("COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) AS expense")
->first();
$income = (int) $row->income;
$expense = (int) $row->expense;
return [
'income' => $income,
'expense' => $expense,
'balance' => $income - $expense,
];
}
/**
* Total per kategori untuk satu bulan dan satu jenis transaksi,
* diurutkan dari total terbesar.
*
* @return \Illuminate\Support\Collection<int, array{id: int, name: string, color: string, total: int}>
*/
public function byCategory(User $user, string $month, string $type): Collection
{
$start = Carbon::parse($month . '-01');
return DB::table('transactions')
->join('categories', 'categories.id', '=', 'transactions.category_id')
->where('transactions.user_id', $user->id)
->where('transactions.type', $type)
->whereBetween('transaction_date', [
$start->toDateString(),
$start->copy()->endOfMonth()->toDateString(),
])
->groupBy('categories.id', 'categories.name', 'categories.color')
->selectRaw('categories.id AS id')
->selectRaw('categories.name AS name')
->selectRaw('categories.color AS color')
->selectRaw('SUM(transactions.amount) AS total')
->orderByDesc('total')
->get()
->map(fn (object $row) => [
'id' => (int) $row->id,
'name' => $row->name,
'color' => $row->color,
'total' => (int) $row->total,
]);
}
/**
* Tren arus kas N bulan terakhir sampai (dan termasuk) $untilMonth.
*
* @return \Illuminate\Support\Collection<int, array{month: string, label: string, income: int, expense: int}>
*/
public function monthlyTrend(User $user, int $months = 6, ?string $untilMonth = null): Collection
{
$until = Carbon::parse(($untilMonth ?? now()->format('Y-m')) . '-01')->endOfMonth();
$from = $until->copy()->startOfMonth()->subMonths($months - 1);
$totals = DB::table('transactions')
->where('user_id', $user->id)
->whereBetween('transaction_date', [$from->toDateString(), $until->toDateString()])
->groupBy(DB::raw('substr(transaction_date, 1, 7)'))
->selectRaw("substr(transaction_date, 1, 7) AS month")
->selectRaw("COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) AS income")
->selectRaw("COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) AS expense")
->get()
->keyBy('month');
return Collection::times($months, function (int $i) use ($from, $totals) {
$cursor = $from->copy()->addMonths($i - 1);
$key = $cursor->format('Y-m');
$row = $totals->get($key);
return [
'month' => $key,
'label' => $cursor->translatedFormat('M Y'),
'income' => (int) ($row->income ?? 0),
'expense' => (int) ($row->expense ?? 0),
];
});
}
}
app/Support/Rupiah.php
Unduh berkas<?php
namespace App\Support;
/**
* Format angka rupiah: selalu bulat (tanpa sen), pemisah ribuan titik.
*/
final class Rupiah
{
public static function format(int $amount): string
{
return 'Rp ' . number_format($amount, 0, ',', '.');
}
}
database/migrations/2026_11_01_000010_create_categories_table.php
Unduh berkas<?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('categories', function (Blueprint $table) {
$table->id();
$table->string('name', 60)->unique();
$table->enum('type', ['income', 'expense']);
$table->char('color', 7)->default('#64748b');
$table->timestamps();
$table->index(['type', 'name']);
});
}
public function down(): void
{
Schema::dropIfExists('categories');
}
};
database/migrations/2026_11_01_000020_create_transactions_table.php
Unduh berkas<?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('transactions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')
->constrained()
->cascadeOnDelete();
$table->foreignId('category_id')
->constrained()
->restrictOnDelete();
$table->decimal('amount', 14, 0);
$table->enum('type', ['income', 'expense']);
$table->date('transaction_date');
$table->string('note')->nullable();
$table->timestamps();
$table->index(['user_id', 'transaction_date']);
$table->index(['category_id', 'transaction_date']);
});
}
public function down(): void
{
Schema::dropIfExists('transactions');
}
};
database/seeders/DatabaseSeeder.php
Unduh berkas<?php
namespace Database\Seeders;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$budi = User::create([
'name' => 'Budi Santoso',
'email' => '[email protected]',
'password' => Hash::make('password'),
]);
$categories = [
'Gaji' => ['type' => 'income', 'color' => '#16a34a'],
'Bonus' => ['type' => 'income', 'color' => '#22c55e'],
'Makanan' => ['type' => 'expense', 'color' => '#ef4444'],
'Transportasi' => ['type' => 'expense', 'color' => '#f97316'],
'Hiburan' => ['type' => 'expense', 'color' => '#a855f7'],
];
foreach ($categories as $name => $attributes) {
Category::create(['name' => $name] + $attributes);
}
$rows = [
// [kategori, jenis, nominal, tanggal, catatan]
['Gaji', 'income', 5000000, '2026-10-01', 'Gaji bulan Oktober'],
['Bonus', 'income', 500000, '2026-10-10', 'Bonus proyek aplikasi'],
['Makanan', 'expense', 25000, '2026-10-04', 'Makan siang di kantin'],
['Makanan', 'expense', 45000, '2026-10-07', 'Kopi dan camilan'],
['Makanan', 'expense', 60000, '2026-10-12', 'Belanja bahan makanan'],
['Transportasi', 'expense', 15000, '2026-10-03', 'Ojek ke kantor'],
['Transportasi', 'expense', 20000, '2026-10-09', 'Isi bensin motor'],
['Hiburan', 'expense', 80000, '2026-10-11', 'Tiket bioskop akhir pekan'],
['Gaji', 'income', 4800000, '2026-09-01', 'Gaji bulan September'],
['Makanan', 'expense', 40000, '2026-09-16', 'Makan malam bersama teman'],
['Makanan', 'expense', 30000, '2026-08-02', 'Camilan akhir bulan'],
];
foreach ($rows as [$categoryName, $type, $amount, $date, $note]) {
$budi->transactions()->create([
'category_id' => Category::where('name', $categoryName)->value('id'),
'type' => $type,
'amount' => $amount,
'transaction_date' => $date,
'note' => $note,
]);
}
$this->command?->info('Transaksi terisi: ' . Transaction::count());
}
}
routes/web.php
Unduh berkas<?php
use App\Http\Controllers\AuthController;
use App\Http\Controllers\CategoryController;
use App\Http\Controllers\ReportController;
use App\Http\Controllers\TransactionController;
use Illuminate\Support\Facades\Route;
Route::get('/', fn () => redirect()->route('transactions.index'))->name('home');
Route::middleware('guest')->group(function () {
Route::get('login', [AuthController::class, 'showLogin'])->name('login');
Route::post('login', [AuthController::class, 'login'])->name('login.attempt');
});
Route::post('logout', [AuthController::class, 'logout'])
->middleware('auth')
->name('logout');
Route::middleware('auth')->group(function () {
Route::resource('transactions', TransactionController::class);
Route::resource('categories', CategoryController::class)
->except('show');
Route::get('reports', [ReportController::class, 'current'])->name('reports.index');
Route::get('reports/{month}', [ReportController::class, 'show'])
->where('month', '[0-9]{4}-[0-9]{2}')
->name('reports.show');
});
tests/Feature/AuthTest.php
Unduh berkas<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AuthTest extends TestCase
{
use RefreshDatabase;
public function test_tamu_diarahkan_ke_halaman_login(): void
{
$this->get(route('transactions.index'))
->assertRedirect(route('login'));
}
public function test_login_berhasil_dan_gagal(): void
{
User::factory()->create([
'name' => 'Budi Santoso',
'email' => '[email protected]',
'password' => bcrypt('password-rahasia'),
]);
// Kredensial salah: kembali dengan error.
$this->from(route('login'))
->post(route('login.attempt'), [
'email' => '[email protected]',
'password' => 'salah',
])
->assertSessionHasErrors('email');
// Kredensial benar: masuk dan diarahkan ke daftar transaksi.
$this->post(route('login.attempt'), [
'email' => '[email protected]',
'password' => 'password-rahasia',
])
->assertRedirect(route('transactions.index'));
$this->assertAuthenticated();
}
}
tests/Feature/CategoryTest.php
Unduh berkas<?php
namespace Tests\Feature;
use App\Models\Category;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CategoryTest extends TestCase
{
use RefreshDatabase;
public function test_bisa_menambah_kategori(): void
{
$user = User::factory()->create([
'name' => 'Budi Santoso',
'email' => '[email protected]',
]);
$response = $this->actingAs($user)->post(route('categories.store'), [
'name' => 'Transportasi',
'type' => 'expense',
'color' => '#f97316',
]);
$response->assertRedirect(route('categories.index'));
$this->assertDatabaseHas('categories', [
'name' => 'Transportasi',
'type' => 'expense',
'color' => '#f97316',
]);
}
public function test_warna_harus_kode_hex_enam_digit(): void
{
$user = User::factory()->create([
'name' => 'Budi Santoso',
'email' => '[email protected]',
]);
foreach (['merah', '#12345', '#zzzzzz'] as $invalidColor) {
$this->actingAs($user)->post(route('categories.store'), [
'name' => 'Kategori Uji',
'type' => 'expense',
'color' => $invalidColor,
])->assertSessionHasErrors('color');
}
$this->assertDatabaseCount('categories', 0);
}
public function test_nama_kategori_harus_unik(): void
{
$user = User::factory()->create([
'name' => 'Budi Santoso',
'email' => '[email protected]',
]);
Category::create([
'name' => 'Makanan',
'type' => 'expense',
'color' => '#ef4444',
]);
$this->actingAs($user)->post(route('categories.store'), [
'name' => 'Makanan',
'type' => 'expense',
'color' => '#ef4444',
])->assertSessionHasErrors('name');
$this->assertDatabaseCount('categories', 1);
}
public function test_bisa_mengubah_kategori_dengan_nama_sendiri(): void
{
$user = User::factory()->create([
'name' => 'Budi Santoso',
'email' => '[email protected]',
]);
$category = Category::create([
'name' => 'Makanan',
'type' => 'expense',
'color' => '#ef4444',
]);
// Nama yang sama tetap boleh: aturan unique mengabaikan baris sendiri.
$this->actingAs($user)
->put(route('categories.update', $category), [
'name' => 'Makanan',
'type' => 'expense',
'color' => '#dc2626',
])
->assertRedirect(route('categories.index'));
$this->assertDatabaseHas('categories', [
'id' => $category->id,
'name' => 'Makanan',
'color' => '#dc2626',
]);
}
public function test_jenis_tidak_bisa_berubah_setelah_dipakai(): void
{
$user = User::factory()->create([
'name' => 'Budi Santoso',
'email' => '[email protected]',
]);
$category = Category::create([
'name' => 'Makanan',
'type' => 'expense',
'color' => '#ef4444',
]);
$user->transactions()->create([
'category_id' => $category->id,
'type' => 'expense',
'amount' => 45000,
'transaction_date' => '2026-10-05',
]);
$this->actingAs($user)
->put(route('categories.update', $category), [
'name' => 'Makanan',
'type' => 'income',
'color' => '#ef4444',
])
->assertSessionHasErrors('type');
$this->assertDatabaseHas('categories', [
'id' => $category->id,
'type' => 'expense',
]);
}
public function test_kategori_dipakai_tidak_bisa_dihapus(): void
{
$user = User::factory()->create([
'name' => 'Budi Santoso',
'email' => '[email protected]',
]);
$used = Category::create([
'name' => 'Makanan',
'type' => 'expense',
'color' => '#ef4444',
]);
$unused = Category::create([
'name' => 'Hiburan',
'type' => 'expense',
'color' => '#a855f7',
]);
$user->transactions()->create([
'category_id' => $used->id,
'type' => 'expense',
'amount' => 45000,
'transaction_date' => '2026-10-05',
]);
$this->actingAs($user)
->delete(route('categories.destroy', $used))
->assertRedirect(route('categories.index'))
->assertSessionHasErrors('name');
$this->assertDatabaseHas('categories', ['id' => $used->id]);
// Kategori tanpa transaksi boleh dihapus.
$this->actingAs($user)
->delete(route('categories.destroy', $unused))
->assertRedirect(route('categories.index'));
$this->assertDatabaseMissing('categories', ['id' => $unused->id]);
}
}
tests/Feature/ExampleTest.php
Unduh berkas<?php
namespace Tests\Feature;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* Halaman depan cukup meneruskan pengunjung ke daftar transaksi;
* dari sana middleware auth yang meneruskan tamu ke halaman login.
*/
public function test_halaman_depan_meneruskan_ke_daftar_transaksi(): void
{
$this->get('/')
->assertRedirect(route('transactions.index'));
}
}
tests/Feature/ReportCalculationTest.php
Unduh berkas<?php
namespace Tests\Feature;
use App\Models\Category;
use App\Models\User;
use App\Services\ReportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ReportCalculationTest extends TestCase
{
use RefreshDatabase;
/**
* Data tetap dengan angka yang mudah dihitung manual.
*
* Oktober 2026 (milik Budi):
* - Pemasukan: Gaji 5.000.000 + Bonus 500.000 = 5.500.000
* - Pengeluaran: Makanan 130.000, Hiburan 80.000,
* Transportasi 35.000 = 245.000
* - Selisih = 5.255.000
*/
private function seedBudi(): User
{
$budi = User::factory()->create([
'name' => 'Budi Santoso',
'email' => '[email protected]',
]);
$gaji = Category::create(['name' => 'Gaji', 'type' => 'income', 'color' => '#16a34a']);
$bonus = Category::create(['name' => 'Bonus', 'type' => 'income', 'color' => '#22c55e']);
$makanan = Category::create(['name' => 'Makanan', 'type' => 'expense', 'color' => '#ef4444']);
$transportasi = Category::create(['name' => 'Transportasi', 'type' => 'expense', 'color' => '#f97316']);
$hiburan = Category::create(['name' => 'Hiburan', 'type' => 'expense', 'color' => '#a855f7']);
$budi->transactions()->createMany([
['category_id' => $gaji->id, 'type' => 'income', 'amount' => 5000000, 'transaction_date' => '2026-10-01', 'note' => 'Gaji bulan Oktober'],
['category_id' => $bonus->id, 'type' => 'income', 'amount' => 500000, 'transaction_date' => '2026-10-10', 'note' => 'Bonus proyek aplikasi'],
['category_id' => $makanan->id, 'type' => 'expense', 'amount' => 25000, 'transaction_date' => '2026-10-04', 'note' => 'Makan siang di kantin'],
['category_id' => $makanan->id, 'type' => 'expense', 'amount' => 45000, 'transaction_date' => '2026-10-07', 'note' => 'Kopi dan camilan'],
['category_id' => $makanan->id, 'type' => 'expense', 'amount' => 60000, 'transaction_date' => '2026-10-12', 'note' => 'Belanja bahan makanan'],
['category_id' => $transportasi->id, 'type' => 'expense', 'amount' => 15000, 'transaction_date' => '2026-10-03', 'note' => 'Ojek ke kantor'],
['category_id' => $transportasi->id, 'type' => 'expense', 'amount' => 20000, 'transaction_date' => '2026-10-09', 'note' => 'Isi bensin motor'],
['category_id' => $hiburan->id, 'type' => 'expense', 'amount' => 80000, 'transaction_date' => '2026-10-11', 'note' => 'Tiket bioskop akhir pekan'],
['category_id' => $gaji->id, 'type' => 'income', 'amount' => 4800000, 'transaction_date' => '2026-09-01', 'note' => 'Gaji bulan September'],
['category_id' => $makanan->id, 'type' => 'expense', 'amount' => 40000, 'transaction_date' => '2026-09-16', 'note' => 'Makan malam bersama teman'],
['category_id' => $makanan->id, 'type' => 'expense', 'amount' => 30000, 'transaction_date' => '2026-08-02', 'note' => 'Camilan akhir bulan'],
]);
return $budi;
}
public function test_ringkasan_bulan_oktober(): void
{
$budi = $this->seedBudi();
$summary = app(ReportService::class)->monthlySummary($budi, '2026-10');
$this->assertSame(5500000, $summary['income']);
$this->assertSame(245000, $summary['expense']);
$this->assertSame(5255000, $summary['balance']);
}
public function test_ringkasan_bulan_september(): void
{
$budi = $this->seedBudi();
$summary = app(ReportService::class)->monthlySummary($budi, '2026-09');
$this->assertSame(4800000, $summary['income']);
$this->assertSame(40000, $summary['expense']);
$this->assertSame(4760000, $summary['balance']);
}
public function test_bulan_tanpa_transaksi_menghasilkan_nol(): void
{
$budi = $this->seedBudi();
$summary = app(ReportService::class)->monthlySummary($budi, '2025-01');
$this->assertSame(0, $summary['income']);
$this->assertSame(0, $summary['expense']);
$this->assertSame(0, $summary['balance']);
}
public function test_total_pengeluaran_per_kategori_bulan_oktober(): void
{
$budi = $this->seedBudi();
$rows = app(ReportService::class)
->byCategory($budi, '2026-10', 'expense');
$this->assertSame(
[
['id' => 3, 'name' => 'Makanan', 'color' => '#ef4444', 'total' => 130000],
['id' => 5, 'name' => 'Hiburan', 'color' => '#a855f7', 'total' => 80000],
['id' => 4, 'name' => 'Transportasi', 'color' => '#f97316', 'total' => 35000],
],
$rows->all(),
);
}
public function test_total_pemasukan_per_kategori_bulan_oktober(): void
{
$budi = $this->seedBudi();
$rows = app(ReportService::class)
->byCategory($budi, '2026-10', 'income');
$this->assertSame(
[
['id' => 1, 'name' => 'Gaji', 'color' => '#16a34a', 'total' => 5000000],
['id' => 2, 'name' => 'Bonus', 'color' => '#22c55e', 'total' => 500000],
],
$rows->all(),
);
}
public function test_tren_arus_kas_enam_bulan(): void
{
$budi = $this->seedBudi();
$trend = app(ReportService::class)
->monthlyTrend($budi, 6, '2026-10');
$this->assertSame(
['2026-05', '2026-06', '2026-07', '2026-08', '2026-09', '2026-10'],
$trend->pluck('month')->all(),
);
$byMonth = $trend->keyBy('month');
$this->assertSame(0, $byMonth['2026-05']['income']);
$this->assertSame(0, $byMonth['2026-05']['expense']);
$this->assertSame(30000, $byMonth['2026-08']['expense']);
$this->assertSame(4800000, $byMonth['2026-09']['income']);
$this->assertSame(40000, $byMonth['2026-09']['expense']);
$this->assertSame(5500000, $byMonth['2026-10']['income']);
$this->assertSame(245000, $byMonth['2026-10']['expense']);
}
public function test_laporan_hanya_menghitung_transaksi_milik_sendiri(): void
{
$budi = $this->seedBudi();
$siti = User::factory()->create([
'name' => 'Siti Rahayu',
'email' => '[email protected]',
]);
$gaji = Category::where('name', 'Gaji')->first();
$siti->transactions()->create([
'category_id' => $gaji->id,
'type' => 'income',
'amount' => 2000000,
'transaction_date' => '2026-10-02',
]);
$service = app(ReportService::class);
// Laporan Budi tidak berubah oleh transaksi Siti.
$this->assertSame(5500000, $service->monthlySummary($budi, '2026-10')['income']);
// Laporan Siti hanya berisi transaksinya sendiri.
$this->assertSame(2000000, $service->monthlySummary($siti, '2026-10')['income']);
$this->assertSame(0, $service->monthlySummary($siti, '2026-10')['expense']);
}
public function test_halaman_laporan_menampilkan_angka_dan_batang(): void
{
$budi = $this->seedBudi();
$response = $this->actingAs($budi)->get(route('reports.show', '2026-10'));
$response->assertOk()
->assertSee('Laporan Oktober 2026')
->assertSee('Rp 5.500.000')
->assertSee('Rp 245.000')
->assertSee('Rp 5.255.000')
// Lebar batang kategori: persentase dari total pengeluaran.
->assertSee('width: 53%') // Makanan: 130.000 / 245.000
->assertSee('width: 33%') // Hiburan: 80.000 / 245.000
->assertSee('width: 14%') // Transportasi: 35.000 / 245.000
// Lebar batang tren: persentase dari nilai terbesar (5.500.000).
->assertSee('width: 100%') // Pemasukan Oktober.
->assertSee('width: 4%'); // Pengeluaran Oktober: 245.000 / 5.500.000.
}
public function test_bulan_tidak_valid_ditolak(): void
{
$budi = $this->seedBudi();
$this->actingAs($budi)->get('/reports/2026-13')->assertNotFound();
$this->actingAs($budi)->get('/reports/bulan-ini')->assertNotFound();
}
}
tests/Feature/TransactionCrudTest.php
Unduh berkas<?php
namespace Tests\Feature;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TransactionCrudTest extends TestCase
{
use RefreshDatabase;
private function user(): User
{
return User::factory()->create([
'name' => 'Budi Santoso',
'email' => '[email protected]',
]);
}
/**
* @return array<string, Category>
*/
private function categories(): array
{
return [
'income' => Category::create([
'name' => 'Gaji',
'type' => 'income',
'color' => '#16a34a',
]),
'expense' => Category::create([
'name' => 'Makanan',
'type' => 'expense',
'color' => '#ef4444',
]),
];
}
public function test_bisa_mencatat_transaksi_pengeluaran(): void
{
$user = $this->user();
$categories = $this->categories();
$response = $this->actingAs($user)->post(route('transactions.store'), [
'category_id' => $categories['expense']->id,
'type' => 'expense',
'amount' => 45000,
'transaction_date' => '2026-10-05',
'note' => 'Makan siang di kantin',
]);
$response->assertRedirect(route('transactions.index'));
$this->assertDatabaseHas('transactions', [
'user_id' => $user->id,
'category_id' => $categories['expense']->id,
'type' => 'expense',
'amount' => 45000,
'transaction_date' => '2026-10-05',
'note' => 'Makan siang di kantin',
]);
}
public function test_bisa_mencatat_transaksi_pemasukan(): void
{
$user = $this->user();
$categories = $this->categories();
$this->actingAs($user)->post(route('transactions.store'), [
'category_id' => $categories['income']->id,
'type' => 'income',
'amount' => 5000000,
'transaction_date' => '2026-10-01',
'note' => 'Gaji bulan Oktober',
]);
$this->assertDatabaseHas('transactions', [
'user_id' => $user->id,
'type' => 'income',
'amount' => 5000000,
]);
}
public function test_nominal_wajib_bulat_dan_minimal_satu(): void
{
$user = $this->user();
$categories = $this->categories();
foreach ([0, -100, 2500.5] as $invalidAmount) {
$this->actingAs($user)->post(route('transactions.store'), [
'category_id' => $categories['expense']->id,
'type' => 'expense',
'amount' => $invalidAmount,
'transaction_date' => '2026-10-05',
'note' => 'Nominal tidak valid',
])->assertSessionHasErrors('amount');
}
$this->assertDatabaseCount('transactions', 0);
}
public function test_tanggal_masa_depan_ditolak(): void
{
$user = $this->user();
$categories = $this->categories();
$this->actingAs($user)->post(route('transactions.store'), [
'category_id' => $categories['expense']->id,
'type' => 'expense',
'amount' => 15000,
'transaction_date' => now()->addDay()->toDateString(),
'note' => 'Belum terjadi',
])->assertSessionHasErrors('transaction_date');
}
public function test_kategori_harus_cocok_dengan_jenis_transaksi(): void
{
$user = $this->user();
$categories = $this->categories();
// Jenis expense, tetapi kategori yang dikirim bertipe income.
$this->actingAs($user)->post(route('transactions.store'), [
'category_id' => $categories['income']->id,
'type' => 'expense',
'amount' => 50000,
'transaction_date' => '2026-10-05',
'note' => 'Salah kombinasi',
])->assertSessionHasErrors('category_id');
$this->assertDatabaseCount('transactions', 0);
}
public function test_bisa_mengubah_transaksi(): void
{
$user = $this->user();
$categories = $this->categories();
$transaction = $user->transactions()->create([
'category_id' => $categories['expense']->id,
'type' => 'expense',
'amount' => 45000,
'transaction_date' => '2026-10-05',
'note' => 'Kopi sore',
]);
$this->actingAs($user)
->put(route('transactions.update', $transaction), [
'category_id' => $categories['expense']->id,
'type' => 'expense',
'amount' => 55000,
'transaction_date' => '2026-10-06',
'note' => 'Kopi sore dan camilan',
])
->assertRedirect(route('transactions.index'));
$this->assertDatabaseHas('transactions', [
'id' => $transaction->id,
'amount' => 55000,
'transaction_date' => '2026-10-06',
]);
$this->assertDatabaseCount('transactions', 1);
}
public function test_bisa_menghapus_transaksi(): void
{
$user = $this->user();
$categories = $this->categories();
$transaction = $user->transactions()->create([
'category_id' => $categories['expense']->id,
'type' => 'expense',
'amount' => 45000,
'transaction_date' => '2026-10-05',
]);
$this->actingAs($user)
->delete(route('transactions.destroy', $transaction))
->assertRedirect(route('transactions.index'));
$this->assertDatabaseMissing('transactions', ['id' => $transaction->id]);
}
public function test_transaksi_pengguna_lain_tidak_bisa_diakses(): void
{
$budi = $this->user();
$categories = $this->categories();
$transaction = $budi->transactions()->create([
'category_id' => $categories['expense']->id,
'type' => 'expense',
'amount' => 45000,
'transaction_date' => '2026-10-05',
'note' => 'Catatan milik Budi',
]);
$siti = User::factory()->create([
'name' => 'Siti Rahayu',
'email' => '[email protected]',
]);
$this->actingAs($siti)->get(route('transactions.edit', $transaction))->assertForbidden();
$this->actingAs($siti)->put(route('transactions.update', $transaction), [
'category_id' => $categories['expense']->id,
'type' => 'expense',
'amount' => 1,
'transaction_date' => '2026-10-05',
])->assertForbidden();
$this->actingAs($siti)->delete(route('transactions.destroy', $transaction))->assertForbidden();
// Daftar milik Siti tidak menampilkan transaksi milik Budi.
$this->actingAs($siti)
->get(route('transactions.index'))
->assertOk()
->assertDontSee('Catatan milik Budi');
$this->assertDatabaseHas('transactions', ['id' => $transaction->id, 'amount' => 45000]);
}
public function test_filter_bulan_pada_daftar_transaksi(): void
{
$user = $this->user();
$categories = $this->categories();
$user->transactions()->createMany([
[
'category_id' => $categories['expense']->id,
'type' => 'expense',
'amount' => 40000,
'transaction_date' => '2026-09-16',
'note' => 'Makan malam bulan lalu',
],
[
'category_id' => $categories['expense']->id,
'type' => 'expense',
'amount' => 25000,
'transaction_date' => '2026-10-04',
'note' => 'Makan siang bulan ini',
],
]);
$this->actingAs($user)
->get(route('transactions.index', ['month' => '2026-10']))
->assertOk()
->assertSee('Makan siang bulan ini')
->assertDontSee('Makan malam bulan lalu');
}
}
tests/Unit/ExampleTest.php
Unduh berkas<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_that_true_is_true(): void
{
$this->assertTrue(true);
}
}