diff --git a/docs/superpowers/plans/2026-08-14-nik-decoder.md b/docs/superpowers/plans/2026-08-14-nik-decoder.md new file mode 100644 index 0000000..f784611 --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-nik-decoder.md @@ -0,0 +1,34 @@ +# NIK / KTP Decoder — Spec + Plan + +**Date:** 2026-08-14 · Category: Dev · id: `nik-decoder` + +## Goal +Validate an Indonesian **NIK** (16-digit Nomor Induk Kependudukan / KTP number) and break out its structure — province, regency & district codes, birth date, gender, and serial — entirely client-side. Zero uploads. + +## NIK structure (16 digits) +`PP KK CC | DD MM YY | SSSS` +- `PP` province code (2) → mapped to province name (38-province table, incl. 2022 additions). +- `KK` regency/city code (2), `CC` district/kecamatan code (2) — shown as codes (full name mapping needs the ~80k-row Kemendagri dataset, out of scope; codes + province name are the useful, embeddable part). +- `DD MM YY` birth date. **Female → day + 40** (so `DD` 41–71 means female, real day = DD−40). +- `SSSS` computer-generated serial (0001–9999). +- No checksum digit → validation is structural (length, digits, known province, valid month/day). + +## Architecture +### Pure lib `src/tools/dev/nik.lib.ts` +- `PROVINCES: Record` (province code → name). +- `parseNik(nik: string, currentYear: number): NikResult` — pure, `currentYear` passed in (island passes `new Date().getFullYear()`) so the century heuristic stays deterministic/testable. +- Century: `fullYear = 2000+yy <= currentYear ? 2000+yy : 1900+yy`. +- `NikResult = { valid: boolean; issues: string[]; provinceCode; province; regencyCode; districtCode; gender: 'male'|'female'; birthDate: { day; month; year } | null; birthDateISO: string | null; serial; }`. +- Validates: 16 digits; province in table (else issue, still decodes); month 1–12; real day 1–31. + +### Island `src/islands/dev/NikDecoder.tsx` +Input (controlled, digit-filtered) → `useMemo(parseNik(value, new Date().getFullYear()))` → validity badge + labeled rows (province, regency code, district code, gender, birth date, age, serial) + `CopyButton`. Example button. i18n TR en+id. SSR-safe. + +### Registry + SEO +`tools.ts` ToolDef (icon `SquareUser`). EN + ID `nik-decoder` SEO (title/description/intro/howTo/faqs). Keywords: "cek NIK", "decode NIK KTP", "NIK validator", "arti NIK". Bahasa "tool" loanword. **Privacy note in copy/UI:** decoding is local; nothing is uploaded (important for PII). + +## Testing +`nik.lib.test.ts`: valid male NIK → fields; female (day+40) → gender female + corrected day; province lookup; century heuristic (pass fixed currentYear); invalids (length, non-digit, month 13, day 00) → issues; unknown province flagged. + +## Definition of done +Spec+plan committed · lib unit-tested · EN+ID SEO w/ howTo · vitest+lint+build green · `/tools/nik-decoder` + `/id/…` built · develop → main → live verified · PWA hard-refresh note. diff --git a/src/islands/dev/NikDecoder.tsx b/src/islands/dev/NikDecoder.tsx new file mode 100644 index 0000000..c1965b1 --- /dev/null +++ b/src/islands/dev/NikDecoder.tsx @@ -0,0 +1,138 @@ +import { useMemo, useState } from 'react'; +import { Alert } from '@/components/ui/Alert'; +import { CopyButton } from '@/components/ui/CopyButton'; +import { parseNik } from '@/tools/dev/nik.lib'; +import type { Lang } from '@/i18n/config'; + +const MONTHS: Record = { + en: ['', 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], + id: ['', 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'], +}; + +const TR: Record = { + en: { + intro: 'Validate an Indonesian NIK (the 16-digit KTP number) and break out the province, birth date, gender and serial. It runs entirely in your browser — the NIK is never uploaded.', + label: 'NIK (16 digits)', + valid: 'Valid structure', + invalid: 'Invalid', + labels: { province: 'Province', regency: 'Regency/city code', district: 'District code', gender: 'Gender', birthDate: 'Birth date', age: 'Age', serial: 'Serial' }, + male: 'Male', female: 'Female', years: 'years', + example: 'Load example', + privacy: 'A NIK is personal data — this tool decodes it locally and never sends it anywhere.', + }, + id: { + intro: 'Validasi NIK Indonesia (nomor KTP 16 digit) dan uraikan provinsi, tanggal lahir, jenis kelamin, dan serial. Berjalan sepenuhnya di browser Anda — NIK tidak pernah diunggah.', + label: 'NIK (16 digit)', + valid: 'Struktur valid', + invalid: 'Tidak valid', + labels: { province: 'Provinsi', regency: 'Kode kabupaten/kota', district: 'Kode kecamatan', gender: 'Jenis kelamin', birthDate: 'Tanggal lahir', age: 'Usia', serial: 'Serial' }, + male: 'Laki-laki', female: 'Perempuan', years: 'tahun', + example: 'Muat contoh', + privacy: 'NIK adalah data pribadi — tool ini men-decode secara lokal dan tidak pernah mengirimnya ke mana pun.', + }, +}; + +function ageFrom(iso: string): number { + const b = new Date(iso + 'T00:00:00'); + const now = new Date(); + let age = now.getFullYear() - b.getFullYear(); + const m = now.getMonth() - b.getMonth(); + if (m < 0 || (m === 0 && now.getDate() < b.getDate())) age--; + return age; +} + +export default function NikDecoder({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [value, setValue] = useState(''); + + const result = useMemo(() => (value.trim() ? parseNik(value, new Date().getFullYear()) : null), [value]); + + const rows = result + ? [ + { label: t.labels.province, value: result.province ? `${result.province} (${result.provinceCode})` : `— (${result.provinceCode})` }, + { label: t.labels.regency, value: result.regencyCode }, + { label: t.labels.district, value: result.districtCode }, + { label: t.labels.gender, value: result.gender === 'female' ? t.female : t.male }, + { + label: t.labels.birthDate, + value: result.birthDate + ? `${result.birthDate.day} ${MONTHS[lang][result.birthDate.month]} ${result.birthDate.year}` + : '—', + }, + { label: t.labels.age, value: result.birthDateISO ? `${ageFrom(result.birthDateISO)} ${t.years}` : '—' }, + { label: t.labels.serial, value: result.serial }, + ] + : []; + + const copyText = result ? rows.map(r => `${r.label}: ${r.value}`).join('\n') : ''; + + return ( +
+

{t.intro}

+ +
+
+ {t.label} + +
+ setValue(e.target.value.replace(/[^\d\s.-]/g, '').slice(0, 20))} + inputMode="numeric" + spellCheck={false} + className="w-full border-2 border-border bg-muted p-3 font-mono text-lg tracking-wider" + placeholder="3201234567890001" + /> +
+ +

{t.privacy}

+ + {result && ( +
+
+ + {result.valid ? t.valid : t.invalid} + + +
+ + {result.issues.length > 0 && ( + +
    + {result.issues.map((iss, i) =>
  • {iss}
  • )} +
+
+ )} + + {result.birthDate && ( +
+ {rows.map(r => ( +
+ {r.label} + {r.value} +
+ ))} +
+ )} +
+ )} +
+ ); +} diff --git a/src/islands/dev/TaxCalculator.tsx b/src/islands/dev/TaxCalculator.tsx new file mode 100644 index 0000000..153621a --- /dev/null +++ b/src/islands/dev/TaxCalculator.tsx @@ -0,0 +1,124 @@ +import { useMemo, useState } from 'react'; +import { CopyButton } from '@/components/ui/CopyButton'; +import { computePpn, computePph, PPN_RATES, PPH_PRESETS } from '@/tools/dev/tax.lib'; +import type { Lang } from '@/i18n/config'; + +const TR: Record = { + en: { + intro: 'Calculate PPN (VAT) and PPh (withholding) on an invoice amount. Everything is computed in your browser.', + amount: 'Amount (Rp)', + ppnRate: 'PPN rate', + inclusive: 'Amount already includes PPN', + pph: 'PPh withholding', + none: 'None', + dpp: 'Base (DPP)', + ppn: 'PPN', + invoiceTotal: 'Invoice total (DPP + PPN)', + pphWithheld: 'PPh withheld', + netToVendor: 'Net paid to vendor', + }, + id: { + intro: 'Hitung PPN dan PPh (potongan) pada nilai invoice. Semua dihitung di browser Anda.', + amount: 'Nominal (Rp)', + ppnRate: 'Tarif PPN', + inclusive: 'Nominal sudah termasuk PPN', + pph: 'Potongan PPh', + none: 'Tidak ada', + dpp: 'Dasar (DPP)', + ppn: 'PPN', + invoiceTotal: 'Total invoice (DPP + PPN)', + pphWithheld: 'PPh dipotong', + netToVendor: 'Diterima vendor', + }, +}; + +const rp = (n: number) => 'Rp ' + new Intl.NumberFormat('id-ID').format(n); + +export default function TaxCalculator({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [amount, setAmount] = useState('1000000'); + const [ppnRate, setPpnRate] = useState(11); + const [inclusive, setInclusive] = useState(false); + const [pphRate, setPphRate] = useState(0); + + const result = useMemo(() => { + const value = Number(amount.replace(/[.,\s]/g, '')); + if (!Number.isFinite(value) || value <= 0) return null; + const ppn = computePpn(value, ppnRate / 100, inclusive); + const pph = pphRate > 0 ? computePph(ppn.dpp, pphRate) : { pph: 0, net: ppn.dpp }; + return { ...ppn, pphAmount: pph.pph, net: ppn.total - pph.pph }; + }, [amount, ppnRate, inclusive, pphRate]); + + const rows = result + ? [ + { label: t.dpp, value: rp(result.dpp) }, + { label: t.ppn, value: rp(result.ppn) }, + { label: t.invoiceTotal, value: rp(result.total), strong: true }, + ...(pphRate > 0 ? [{ label: t.pphWithheld, value: '− ' + rp(result.pphAmount) }] : []), + ...(pphRate > 0 ? [{ label: t.netToVendor, value: rp(result.net), strong: true }] : []), + ] + : []; + + return ( +
+

{t.intro}

+ + + +
+ + +
+ + + + {result && ( +
+
+ `${r.label}: ${r.value}`).join('\n')} /> +
+
+ {rows.map(r => ( +
+ {r.label} + {r.value} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/src/islands/dev/Terbilang.tsx b/src/islands/dev/Terbilang.tsx new file mode 100644 index 0000000..b609062 --- /dev/null +++ b/src/islands/dev/Terbilang.tsx @@ -0,0 +1,68 @@ +import { useMemo, useState } from 'react'; +import { CopyButton } from '@/components/ui/CopyButton'; +import { terbilang, terbilangRupiah, capitalize } from '@/tools/dev/terbilang.lib'; +import type { Lang } from '@/i18n/config'; + +const TR: Record = { + en: { + intro: 'Convert a number into Indonesian words (terbilang) — handy for invoices, cheques and kwitansi. Runs entirely in your browser.', + label: 'Number', + words: 'In words', + rupiah: 'As rupiah', + placeholder: 'e.g. 1500000', + }, + id: { + intro: 'Ubah angka menjadi terbilang (kata-kata Bahasa Indonesia) — berguna untuk invoice, cek, dan kwitansi. Berjalan sepenuhnya di browser Anda.', + label: 'Angka', + words: 'Terbilang', + rupiah: 'Dalam rupiah', + placeholder: 'mis. 1500000', + }, +}; + +export default function Terbilang({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [value, setValue] = useState(''); + + const num = useMemo(() => { + const cleaned = value.replace(/[.,\s]/g, ''); + return /^-?\d+$/.test(cleaned) ? Number(cleaned) : null; + }, [value]); + + const words = num !== null ? capitalize(terbilang(num)) : ''; + const rupiah = num !== null ? capitalize(terbilangRupiah(num)) : ''; + const grouped = num !== null ? new Intl.NumberFormat('id-ID').format(num) : ''; + + return ( +
+

{t.intro}

+ + + + {num !== null && ( +
+ {[{ label: t.words, text: words }, { label: t.rupiah, text: rupiah }].map(o => ( +
+
+ {o.label} + +
+
{o.text}
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index 4056c5c..e5c2d6e 100644 --- a/src/registry/tool-seo.ts +++ b/src/registry/tool-seo.ts @@ -195,6 +195,58 @@ const en: Record = { { q: 'Does it work offline?', a: 'Yes. As a PWA it keeps working with no connection once loaded.' }, ], }, + 'nik-decoder': { + title: 'Free NIK / KTP Decoder — Validate & Read an Indonesian ID Number', + description: 'Validate an Indonesian NIK (16-digit KTP number) and decode the province, birth date, gender and serial. Runs in your browser — the NIK is never uploaded.', + intro: 'This free NIK decoder checks the structure of an Indonesian NIK (the 16-digit number on a KTP) and breaks it down: province, regency and district codes, birth date, gender (via the day+40 rule) and the registration serial. Because a NIK is personal data, everything is decoded locally in your browser and nothing is ever uploaded.', + howTo: [ + 'Type or paste the 16-digit NIK.', + 'See whether the structure is valid, and read the province, birth date and gender.', + 'Check the age, computed from the decoded birth date.', + 'Copy the breakdown if you need it.', + ], + faqs: [ + { q: 'Is my NIK uploaded anywhere?', a: 'No. A NIK is sensitive personal data, so this tool decodes it entirely in your browser — it is never sent to a server.' }, + { q: 'How is gender encoded in a NIK?', a: 'The birth day is stored in positions 7–8. For women, 40 is added to the day, so a value of 41–71 means female and the real day is that number minus 40.' }, + { q: 'Can it show the full district name?', a: 'It decodes the province name and shows the regency and district code numbers. Mapping those codes to full names needs the official Kemendagri region dataset, which is out of scope here.' }, + { q: 'Does a valid structure mean the NIK is real?', a: 'No. A NIK has no checksum, so this tool can confirm the format is plausible (valid province, date and length) but cannot verify that a NIK is officially registered.' }, + { q: 'Which year does a 2-digit birth year mean?', a: 'The NIK stores only two year digits, so the tool infers the century: if the 2000s reading would be in the future it uses the 1900s instead.' }, + ], + }, + 'terbilang': { + title: 'Free Terbilang — Convert Numbers to Indonesian Words', + description: 'Convert any number into Indonesian words (terbilang) for invoices, cheques and kwitansi. Instant, in your browser — nothing is uploaded.', + intro: 'This free terbilang tool converts numbers into Indonesian words — for example 1.500.000 becomes “satu juta lima ratus ribu”. It also gives the rupiah form for invoices, cheques and kwitansi. It runs entirely in your browser.', + howTo: [ + 'Type or paste a number.', + 'Read it spelled out in Indonesian words, updated live.', + 'Copy the plain words or the “… rupiah” form.', + ], + faqs: [ + { q: 'Is my input uploaded?', a: 'No. The conversion runs entirely in your browser, so nothing is sent anywhere.' }, + { q: 'How large a number does it handle?', a: 'It spells values up to the trillions (triliun), covering the amounts used on invoices and cheques.' }, + { q: 'Does it produce the rupiah wording?', a: 'Yes. Alongside the plain words it gives the “… rupiah” form used on kwitansi and invoices.' }, + { q: 'Does it work offline?', a: 'Yes. As a PWA it keeps working with no connection once loaded.' }, + ], + }, + 'ppn-pph-calculator': { + title: 'Free PPN & PPh Calculator — Indonesian Invoice Tax', + description: 'Calculate Indonesian PPN (VAT, 11% or 12%) and PPh withholding on an invoice amount. Add or extract PPN, and see the net paid to the vendor. Runs in your browser.', + intro: 'This free calculator works out PPN (VAT) and PPh (withholding tax) on an invoice amount. Add PPN on top of a base or extract it from a VAT-inclusive figure, pick a PPh article (PPh 23, PPh 4(2), PPh 22, PPh 26 or final UMKM), and see the base, PPN, invoice total, amount withheld and net paid to the vendor. Everything is computed in your browser.', + howTo: [ + 'Enter the invoice amount in rupiah.', + 'Choose the PPN rate (11% or 12%) and tick the box if the amount already includes PPN.', + 'Optionally pick a PPh withholding type.', + 'Read the base (DPP), PPN, invoice total, PPh withheld and net to the vendor.', + ], + faqs: [ + { q: 'Is my data uploaded?', a: 'No. All the tax maths runs locally in your browser; nothing is sent to a server.' }, + { q: 'What PPN rate should I use?', a: 'Indonesia’s standard PPN rate is 11%, with 12% applied to certain goods. Pick the rate that applies to your transaction.' }, + { q: 'How is PPh withholding calculated?', a: 'PPh is withheld from the base (DPP) at the rate for the chosen article — e.g. 2% for PPh 23 services or 10% for PPh 4(2) rent. The vendor receives the invoice total minus the PPh.' }, + { q: 'Can it split a VAT-inclusive price?', a: 'Yes. Tick “amount already includes PPN” and it separates the base and the PPN from a gross figure.' }, + { q: 'Is this official tax advice?', a: 'No. It is a calculation helper. Always confirm the correct rates and articles for your situation with a tax professional.' }, + ], + }, 'compare-lists': { title: 'Compare Two Lists — Merge, Dedupe & Diff Lines', description: 'Compare two lists of lines online: merge and remove duplicates, subtract one list from another, or find common lines. Free, private and instant — nothing is uploaded.', @@ -1892,6 +1944,58 @@ const id: Record = { { q: 'Apakah bekerja offline?', a: 'Ya. Sebagai PWA tetap berjalan tanpa koneksi setelah dimuat.' }, ], }, + 'nik-decoder': { + title: 'Cek NIK / KTP Gratis — Validasi & Baca Nomor Induk Kependudukan', + description: 'Validasi NIK Indonesia (nomor KTP 16 digit) dan decode provinsi, tanggal lahir, jenis kelamin, dan serial. Berjalan di browser Anda — NIK tidak pernah diunggah.', + intro: 'Tool cek NIK gratis ini memeriksa struktur NIK Indonesia (nomor 16 digit pada KTP) dan menguraikannya: kode provinsi, kabupaten/kota, dan kecamatan, tanggal lahir, jenis kelamin (lewat aturan tanggal+40), serta serial pendaftaran. Karena NIK adalah data pribadi, semuanya di-decode secara lokal di browser Anda dan tidak pernah diunggah.', + howTo: [ + 'Ketik atau tempel NIK 16 digit.', + 'Lihat apakah strukturnya valid, dan baca provinsi, tanggal lahir, serta jenis kelamin.', + 'Periksa usia yang dihitung dari tanggal lahir hasil decode.', + 'Salin rinciannya bila diperlukan.', + ], + faqs: [ + { q: 'Apakah NIK saya diunggah?', a: 'Tidak. NIK adalah data pribadi sensitif, jadi tool ini men-decode-nya sepenuhnya di browser Anda — tidak pernah dikirim ke server.' }, + { q: 'Bagaimana jenis kelamin dikodekan di NIK?', a: 'Tanggal lahir ada di posisi 7–8. Untuk perempuan, 40 ditambahkan ke tanggal, jadi nilai 41–71 berarti perempuan dan tanggal sebenarnya adalah angka itu dikurangi 40.' }, + { q: 'Bisakah menampilkan nama kecamatan lengkap?', a: 'Tool men-decode nama provinsi dan menampilkan angka kode kabupaten serta kecamatan. Memetakan kode itu ke nama lengkap butuh dataset wilayah resmi Kemendagri, yang di luar cakupan di sini.' }, + { q: 'Apakah struktur valid berarti NIK-nya asli?', a: 'Tidak. NIK tidak memiliki checksum, jadi tool ini dapat memastikan formatnya masuk akal (provinsi, tanggal, dan panjang valid) tetapi tidak dapat memverifikasi bahwa NIK terdaftar resmi.' }, + { q: 'Tahun berapa yang dimaksud dari 2 digit tahun lahir?', a: 'NIK hanya menyimpan dua digit tahun, jadi tool menyimpulkan abadnya: jika pembacaan tahun 2000-an akan berada di masa depan, ia memakai tahun 1900-an.' }, + ], + }, + 'terbilang': { + title: 'Terbilang Gratis — Ubah Angka Menjadi Kata Bahasa Indonesia', + description: 'Ubah angka apa pun menjadi terbilang (kata Bahasa Indonesia) untuk invoice, cek, dan kwitansi. Seketika, di browser Anda — tidak ada yang diunggah.', + intro: 'Tool terbilang gratis ini mengubah angka menjadi kata Bahasa Indonesia — misalnya 1.500.000 menjadi “satu juta lima ratus ribu”. Tersedia juga bentuk rupiah untuk invoice, cek, dan kwitansi. Berjalan sepenuhnya di browser Anda.', + howTo: [ + 'Ketik atau tempel sebuah angka.', + 'Baca ejaannya dalam kata Bahasa Indonesia, diperbarui langsung.', + 'Salin kata polos atau bentuk “… rupiah”.', + ], + faqs: [ + { q: 'Apakah input saya diunggah?', a: 'Tidak. Konversi berjalan sepenuhnya di browser Anda, jadi tidak ada yang dikirim ke mana pun.' }, + { q: 'Seberapa besar angka yang didukung?', a: 'Tool mengeja nilai hingga triliun, mencakup nominal yang dipakai pada invoice dan cek.' }, + { q: 'Apakah menghasilkan penulisan rupiah?', a: 'Ya. Selain kata polos, tersedia bentuk “… rupiah” yang dipakai pada kwitansi dan invoice.' }, + { q: 'Apakah bekerja offline?', a: 'Ya. Sebagai PWA tetap berjalan tanpa koneksi setelah dimuat.' }, + ], + }, + 'ppn-pph-calculator': { + title: 'Kalkulator PPN & PPh Gratis — Pajak Invoice Indonesia', + description: 'Hitung PPN (11% atau 12%) dan potongan PPh pada nilai invoice. Tambah atau pisahkan PPN, dan lihat jumlah yang diterima vendor. Berjalan di browser Anda.', + intro: 'Kalkulator gratis ini menghitung PPN dan PPh (pajak potongan) pada nilai invoice. Tambahkan PPN di atas dasar atau pisahkan dari nilai yang sudah termasuk PPN, pilih jenis PPh (PPh 23, PPh 4(2), PPh 22, PPh 26, atau Final UMKM), lalu lihat dasar, PPN, total invoice, jumlah dipotong, dan yang diterima vendor. Semua dihitung di browser Anda.', + howTo: [ + 'Masukkan nominal invoice dalam rupiah.', + 'Pilih tarif PPN (11% atau 12%) dan centang kotak bila nominal sudah termasuk PPN.', + 'Opsional, pilih jenis potongan PPh.', + 'Baca dasar (DPP), PPN, total invoice, PPh dipotong, dan yang diterima vendor.', + ], + faqs: [ + { q: 'Apakah data saya diunggah?', a: 'Tidak. Semua perhitungan pajak berjalan lokal di browser Anda; tidak ada yang dikirim ke server.' }, + { q: 'Tarif PPN berapa yang harus dipakai?', a: 'Tarif PPN standar Indonesia adalah 11%, dengan 12% untuk barang tertentu. Pilih tarif yang berlaku untuk transaksi Anda.' }, + { q: 'Bagaimana PPh dihitung?', a: 'PPh dipotong dari dasar (DPP) sesuai tarif jenis yang dipilih — mis. 2% untuk jasa PPh 23 atau 10% untuk sewa PPh 4(2). Vendor menerima total invoice dikurangi PPh.' }, + { q: 'Bisakah memisahkan harga yang sudah termasuk PPN?', a: 'Ya. Centang “nominal sudah termasuk PPN” dan tool memisahkan dasar dan PPN dari nilai bruto.' }, + { q: 'Apakah ini nasihat pajak resmi?', a: 'Bukan. Ini tool bantu hitung. Selalu konfirmasi tarif dan pasal yang benar untuk situasi Anda dengan profesional pajak.' }, + ], + }, 'compare-lists': { title: 'Bandingkan Dua Daftar — Gabung, Hapus Duplikat & Diff', description: 'Bandingkan dua daftar baris secara online: gabung dan hapus duplikat, kurangi satu daftar dari yang lain, atau temukan baris yang sama. Gratis, privat, instan — tidak ada yang diunggah.', diff --git a/src/registry/tools.ts b/src/registry/tools.ts index 186f46a..4f39758 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -1,4 +1,4 @@ -import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet, BookOpen, FileType2, FileDown, GitCompare, FileOutput, CalendarClock, ClipboardPaste, PlugZap, Regex, Contact, Wallet, Network, Subtitles, Presentation } from 'lucide-react'; +import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet, BookOpen, FileType2, FileDown, GitCompare, FileOutput, CalendarClock, ClipboardPaste, PlugZap, Regex, Contact, Wallet, Network, Subtitles, Presentation, SquareUser, WholeWord, Percent } from 'lucide-react'; import type { ToolDef } from '@/types/tool'; export const tools: ToolDef[] = [ @@ -289,6 +289,39 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/documents/PptxViewer'), status: 'beta' }, + { + id: 'nik-decoder', + name: 'NIK / KTP Decoder', + category: 'Dev', + route: '/tools/nik-decoder', + keywords: ['nik', 'ktp', 'decode', 'validate', 'cek nik', 'indonesia', 'identity', 'birthdate', 'province'], + icon: SquareUser, + summary: 'Validate an Indonesian NIK and decode province, birth date & gender', + load: () => import('@/islands/dev/NikDecoder'), + status: 'beta' + }, + { + id: 'terbilang', + name: 'Terbilang (Number to Words)', + category: 'Dev', + route: '/tools/terbilang', + keywords: ['terbilang', 'number to words', 'angka', 'kwitansi', 'invoice', 'rupiah', 'spell', 'indonesia'], + icon: WholeWord, + summary: 'Convert numbers to Indonesian words for invoices & cheques', + load: () => import('@/islands/dev/Terbilang'), + status: 'beta' + }, + { + id: 'ppn-pph-calculator', + name: 'PPN & PPh Calculator', + category: 'Dev', + route: '/tools/ppn-pph-calculator', + keywords: ['ppn', 'pph', 'pajak', 'vat', 'tax', 'invoice', 'faktur', 'withholding', 'indonesia'], + icon: Percent, + summary: 'Calculate Indonesian PPN (VAT) and PPh withholding on invoices', + load: () => import('@/islands/dev/TaxCalculator'), + status: 'beta' + }, { id: 'cron-expression', name: 'Cron Expression', diff --git a/src/tools/dev/nik.lib.test.ts b/src/tools/dev/nik.lib.test.ts new file mode 100644 index 0000000..d4c18f9 --- /dev/null +++ b/src/tools/dev/nik.lib.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { parseNik, PROVINCES } from './nik.lib'; + +// 31 (DKI Jakarta) 75 71 | 17 08 90 | 0001 → male, born 1990-08-17 +const MALE = '3175711708900001'; +// same but female: day 17 + 40 = 57 +const FEMALE = '3175715708900001'; + +describe('parseNik', () => { + it('decodes a valid male NIK', () => { + const r = parseNik(MALE, 2026); + expect(r.valid).toBe(true); + expect(r.issues).toEqual([]); + expect(r.provinceCode).toBe('31'); + expect(r.province).toBe('DKI Jakarta'); + expect(r.regencyCode).toBe('75'); + expect(r.districtCode).toBe('71'); + expect(r.gender).toBe('male'); + expect(r.birthDate).toEqual({ day: 17, month: 8, year: 1990 }); + expect(r.birthDateISO).toBe('1990-08-17'); + expect(r.serial).toBe('0001'); + }); + + it('detects female via the day+40 rule and corrects the day', () => { + const r = parseNik(FEMALE, 2026); + expect(r.gender).toBe('female'); + expect(r.birthDate).toEqual({ day: 17, month: 8, year: 1990 }); + expect(r.valid).toBe(true); + }); + + it('applies the century heuristic against currentYear', () => { + // yy=05 with currentYear 2026 → 2005 (not in the future) + expect(parseNik('3175711703050001', 2026).birthDate!.year).toBe(2005); + // yy=99 → 2099 is in the future → 1999 + expect(parseNik('3175711703990001', 2026).birthDate!.year).toBe(1999); + }); + + it('ignores spaces and dots in the input', () => { + expect(parseNik('3175 7117 0890 0001', 2026).valid).toBe(true); + }); + + it('flags a wrong length', () => { + const r = parseNik('317571170890', 2026); + expect(r.valid).toBe(false); + expect(r.issues.join(' ')).toMatch(/16/); + }); + + it('flags non-digits', () => { + expect(parseNik('31757117089000AB', 2026).valid).toBe(false); + }); + + it('flags an impossible month and day', () => { + expect(parseNik('3175711713900001', 2026).issues.join(' ')).toMatch(/month/i); + expect(parseNik('3175710008900001', 2026).issues.join(' ')).toMatch(/day/i); + }); + + it('flags an unknown province but still parses', () => { + const r = parseNik('0075711708900001', 2026); + expect(r.province).toBe(''); + expect(r.issues.join(' ')).toMatch(/province/i); + }); + + it('exposes the province table', () => { + expect(PROVINCES['32']).toBe('Jawa Barat'); + expect(Object.keys(PROVINCES).length).toBeGreaterThanOrEqual(34); + }); +}); diff --git a/src/tools/dev/nik.lib.ts b/src/tools/dev/nik.lib.ts new file mode 100644 index 0000000..ef3cc5d --- /dev/null +++ b/src/tools/dev/nik.lib.ts @@ -0,0 +1,129 @@ +/** + * Indonesian NIK (Nomor Induk Kependudukan / KTP number) decoder — pure. + * + * Layout (16 digits): PP KK CC DDMMYY SSSS + * PP province code KK regency/city code CC district (kecamatan) code + * DD birth day (+40 for female) MM month YY 2-digit year SSSS serial + * + * There is no checksum digit, so validation is structural: length, all digits, + * a known province, and a plausible birth date. Everything runs locally — the + * NIK (which is personal data) is never sent anywhere. + */ + +export const PROVINCES: Record = { + '11': 'Aceh', + '12': 'Sumatera Utara', + '13': 'Sumatera Barat', + '14': 'Riau', + '15': 'Jambi', + '16': 'Sumatera Selatan', + '17': 'Bengkulu', + '18': 'Lampung', + '19': 'Kepulauan Bangka Belitung', + '21': 'Kepulauan Riau', + '31': 'DKI Jakarta', + '32': 'Jawa Barat', + '33': 'Jawa Tengah', + '34': 'DI Yogyakarta', + '35': 'Jawa Timur', + '36': 'Banten', + '51': 'Bali', + '52': 'Nusa Tenggara Barat', + '53': 'Nusa Tenggara Timur', + '61': 'Kalimantan Barat', + '62': 'Kalimantan Tengah', + '63': 'Kalimantan Selatan', + '64': 'Kalimantan Timur', + '65': 'Kalimantan Utara', + '71': 'Sulawesi Utara', + '72': 'Sulawesi Tengah', + '73': 'Sulawesi Selatan', + '74': 'Sulawesi Tenggara', + '75': 'Gorontalo', + '76': 'Sulawesi Barat', + '81': 'Maluku', + '82': 'Maluku Utara', + '91': 'Papua', + '92': 'Papua Barat', + '93': 'Papua Selatan', + '94': 'Papua Tengah', + '95': 'Papua Pegunungan', + '96': 'Papua Barat Daya', +}; + +export interface NikResult { + valid: boolean; + issues: string[]; + provinceCode: string; + province: string; + regencyCode: string; + districtCode: string; + gender: 'male' | 'female'; + birthDate: { day: number; month: number; year: number } | null; + birthDateISO: string | null; + serial: string; +} + +/** Decode and validate a NIK. `currentYear` drives the 2-digit-year century heuristic. */ +export function parseNik(nik: string, currentYear: number): NikResult { + const digits = nik.replace(/[\s.-]/g, ''); + const issues: string[] = []; + + const base: NikResult = { + valid: false, + issues, + provinceCode: '', + province: '', + regencyCode: '', + districtCode: '', + gender: 'male', + birthDate: null, + birthDateISO: null, + serial: '', + }; + + if (digits.length !== 16) { + issues.push(`A NIK must be exactly 16 digits (got ${digits.length}).`); + return base; + } + if (!/^\d{16}$/.test(digits)) { + issues.push('A NIK must contain only digits.'); + return base; + } + + const provinceCode = digits.slice(0, 2); + const regencyCode = digits.slice(2, 4); + const districtCode = digits.slice(4, 6); + const rawDay = Number(digits.slice(6, 8)); + const month = Number(digits.slice(8, 10)); + const yy = Number(digits.slice(10, 12)); + const serial = digits.slice(12, 16); + + const province = PROVINCES[provinceCode] ?? ''; + if (!province) issues.push(`Unknown province code "${provinceCode}".`); + + const gender: 'male' | 'female' = rawDay > 40 ? 'female' : 'male'; + const day = gender === 'female' ? rawDay - 40 : rawDay; + + if (month < 1 || month > 12) issues.push(`Invalid birth month "${digits.slice(8, 10)}".`); + if (day < 1 || day > 31) issues.push(`Invalid birth day "${digits.slice(6, 8)}".`); + + const fullYear = 2000 + yy <= currentYear ? 2000 + yy : 1900 + yy; + const birthValid = month >= 1 && month <= 12 && day >= 1 && day <= 31; + const birthDate = birthValid ? { day, month, year: fullYear } : null; + const p2 = (n: number) => String(n).padStart(2, '0'); + const birthDateISO = birthDate ? `${birthDate.year}-${p2(month)}-${p2(day)}` : null; + + return { + valid: issues.length === 0, + issues, + provinceCode, + province, + regencyCode, + districtCode, + gender, + birthDate, + birthDateISO, + serial, + }; +} diff --git a/src/tools/dev/tax.lib.test.ts b/src/tools/dev/tax.lib.test.ts new file mode 100644 index 0000000..097b2c5 --- /dev/null +++ b/src/tools/dev/tax.lib.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { computePpn, computePph } from './tax.lib'; + +describe('computePpn', () => { + it('adds VAT on top (exclusive)', () => { + expect(computePpn(1_000_000, 0.11, false)).toEqual({ dpp: 1_000_000, ppn: 110_000, total: 1_110_000 }); + }); + it('splits an inclusive amount into base + VAT', () => { + expect(computePpn(1_110_000, 0.11, true)).toEqual({ dpp: 1_000_000, ppn: 110_000, total: 1_110_000 }); + }); + it('rounds to whole rupiah', () => { + expect(computePpn(999_999, 0.11, false).ppn).toBe(110_000); + }); + it('handles the 12% rate', () => { + expect(computePpn(1_000_000, 0.12, false).total).toBe(1_120_000); + }); +}); + +describe('computePph', () => { + it('withholds PPh 23 (2%)', () => { + expect(computePph(1_000_000, 0.02)).toEqual({ pph: 20_000, net: 980_000 }); + }); + it('withholds PPh 4(2) rent (10%)', () => { + expect(computePph(5_000_000, 0.1)).toEqual({ pph: 500_000, net: 4_500_000 }); + }); +}); diff --git a/src/tools/dev/tax.lib.ts b/src/tools/dev/tax.lib.ts new file mode 100644 index 0000000..9143ed8 --- /dev/null +++ b/src/tools/dev/tax.lib.ts @@ -0,0 +1,46 @@ +/** + * Indonesian invoice tax maths — PPN (VAT) and PPh (withholding). Pure. + * Amounts are rounded to whole rupiah, as on tax invoices. + */ + +export interface PpnResult { + dpp: number; // taxable base + ppn: number; // VAT amount + total: number; // dpp + ppn +} + +export interface PphResult { + pph: number; // amount withheld + net: number; // base minus withholding +} + +export const PPN_RATES = [11, 12] as const; + +export const PPH_PRESETS: { label: string; rate: number }[] = [ + { label: 'PPh 23 — Jasa (2%)', rate: 0.02 }, + { label: 'PPh 23 — Sewa & Royalti (15%)', rate: 0.15 }, + { label: 'PPh 4(2) — Sewa tanah/bangunan (10%)', rate: 0.1 }, + { label: 'PPh 22 — Umum (1.5%)', rate: 0.015 }, + { label: 'PPh 26 — WP luar negeri (20%)', rate: 0.2 }, + { label: 'PPh Final UMKM (0.5%)', rate: 0.005 }, +]; + +/** + * Compute PPN. When `inclusive`, `amount` already contains the VAT and is split + * into base + VAT; otherwise `amount` is the base and VAT is added on top. + */ +export function computePpn(amount: number, rate: number, inclusive: boolean): PpnResult { + if (inclusive) { + const dpp = Math.round(amount / (1 + rate)); + return { dpp, ppn: Math.round(amount) - dpp, total: Math.round(amount) }; + } + const dpp = Math.round(amount); + const ppn = Math.round(amount * rate); + return { dpp, ppn, total: dpp + ppn }; +} + +/** Compute PPh withheld on a base amount. */ +export function computePph(base: number, rate: number): PphResult { + const pph = Math.round(base * rate); + return { pph, net: Math.round(base) - pph }; +} diff --git a/src/tools/dev/terbilang.lib.test.ts b/src/tools/dev/terbilang.lib.test.ts new file mode 100644 index 0000000..2851636 --- /dev/null +++ b/src/tools/dev/terbilang.lib.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { terbilang, terbilangRupiah, capitalize } from './terbilang.lib'; + +describe('terbilang', () => { + it.each([ + [0, 'nol'], + [1, 'satu'], + [10, 'sepuluh'], + [11, 'sebelas'], + [12, 'dua belas'], + [19, 'sembilan belas'], + [21, 'dua puluh satu'], + [100, 'seratus'], + [105, 'seratus lima'], + [200, 'dua ratus'], + [1000, 'seribu'], + [1500, 'seribu lima ratus'], + [2026, 'dua ribu dua puluh enam'], + [21500, 'dua puluh satu ribu lima ratus'], + [1000000, 'satu juta'], + [1500000, 'satu juta lima ratus ribu'], + [1000000000, 'satu miliar'], + ])('spells %i as "%s"', (n, expected) => { + expect(terbilang(n)).toBe(expected); + }); + + it('handles negatives and rounding', () => { + expect(terbilang(-5)).toBe('minus lima'); + expect(terbilang(1500.6)).toBe('seribu lima ratus satu'); + }); + + it('formats rupiah', () => { + expect(terbilangRupiah(1500)).toBe('seribu lima ratus rupiah'); + }); + + it('capitalizes', () => { + expect(capitalize('seribu lima ratus rupiah')).toBe('Seribu lima ratus rupiah'); + }); +}); diff --git a/src/tools/dev/terbilang.lib.ts b/src/tools/dev/terbilang.lib.ts new file mode 100644 index 0000000..b893726 --- /dev/null +++ b/src/tools/dev/terbilang.lib.ts @@ -0,0 +1,55 @@ +/** + * Terbilang — spell an integer in Indonesian words. Pure. + * + * Uses "se-" for one hundred/one thousand/ten/eleven (seratus, seribu, + * sepuluh, sebelas) and full words for higher scales (satu juta, satu miliar). + */ + +const ONES = [ + '', 'satu', 'dua', 'tiga', 'empat', 'lima', 'enam', 'tujuh', 'delapan', + 'sembilan', 'sepuluh', 'sebelas', +]; + +function words(n: number): string { + if (n < 12) return ONES[n]; + if (n < 20) return `${words(n - 10)} belas`; + if (n < 100) { + const tens = `${words(Math.floor(n / 10))} puluh`; + return n % 10 ? `${tens} ${words(n % 10)}` : tens; + } + if (n < 200) return n % 100 ? `seratus ${words(n % 100)}` : 'seratus'; + if (n < 1000) { + const h = `${words(Math.floor(n / 100))} ratus`; + return n % 100 ? `${h} ${words(n % 100)}` : h; + } + if (n < 2000) return n % 1000 ? `seribu ${words(n % 1000)}` : 'seribu'; + return scale(n, 1000, 'ribu') ?? scale(n, 1e6, 'juta') ?? scale(n, 1e9, 'miliar') ?? scale(n, 1e12, 'triliun')!; +} + +function scale(n: number, unit: number, name: string): string | null { + if (n >= unit && n < unit * 1000) { + const head = `${words(Math.floor(n / unit))} ${name}`; + const rest = n % unit; + return rest ? `${head} ${words(rest)}` : head; + } + return null; +} + +/** Spell an integer (rounded, sign-aware) in Indonesian words. */ +export function terbilang(value: number): string { + if (!Number.isFinite(value)) return ''; + const n = Math.round(Math.abs(value)); + if (n === 0) return 'nol'; + const w = words(n).replace(/\s+/g, ' ').trim(); + return value < 0 ? `minus ${w}` : w; +} + +/** Spell an amount as Indonesian rupiah (whole-rupiah, "… rupiah"). */ +export function terbilangRupiah(value: number): string { + return `${terbilang(value)} rupiah`; +} + +/** Capitalize the first letter (for display). */ +export function capitalize(s: string): string { + return s ? s[0].toUpperCase() + s.slice(1) : s; +}