nawasara / pbb
PBB tax bill lookup for the Nawasara superapp framework: proxies the Ponorogo sipandaunik system, parses its HTML into JSON, serves it to logged-in residents, and keeps the tax objects they declare.
Requires
- php: ^8.1
- illuminate/support: ^10.0|^12.0
- livewire/livewire: ^3.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-20 08:25:33 UTC
README
PBB (property tax) bill lookup for the Nawasara superapp framework. It sits in front of Ponorogo's tax system, sipandaunik: it calls the NOP search endpoint, parses the HTML response into clean JSON, hides the taxpayer's identity, and serves the result to the citizen app (SuperApps Flutter) behind citizen login.
It also records who checked what, and keeps the tax objects a citizen declares as their own. Those two are separate on purpose; see "Checking is not owning" below, which is the single most important thing to understand about this package.
Status v0.2.0
| Feature | Status |
|---|---|
Look up a bill by NOP (POST /pbb/check) |
ready |
| Parse the year history (paid/unpaid, principal, penalty, total) | ready |
| Mask the name (initial) and drop the address | ready |
| Normalise a plain 18-digit NOP into the separated form | ready |
| Treat an unparseable upstream reply as a failure, not "not found" | ready |
| Record every check that reaches sipandaunik | ready |
Tax objects a citizen declares (GET/POST/DELETE /pbb/objects) |
ready |
| Panel or menu in Nawasara | intentionally none (see below) |
| Retention policy for both tables | waiting on the Finance Department |
Bills themselves are never stored. Every lookup goes straight through to sipandaunik, because a copied bill goes stale without saying so.
There is still no sidebar workspace and no PermissionSeeder: everything here is
gated by the citizen realm JWT, not a Spatie permission. The other Ponorogo Hub
packages (aspirations, citizen, news, emergency, tourism) share the
ponorogo-hub workspace so their menus sit together. This one deliberately has
no config/menu.php at all. If PBB ever grows an admin panel, add the menu with
the same ponorogo-hub workspace id, label and icon as the others.
Setup
Register it in the root composer.json (path repo plus require), then:
composer update nawasara/pbb php artisan migrate
There is no seeder. Configuration through .env:
NAWASARA_PBB_BASE_URL=https://sipandaunik.ponorogo.go.id
NAWASARA_PBB_HTTP_TIMEOUT=20
NAWASARA_PBB_MAX_OBJECTS=20
There is no @source line in app.css. The package ships no Blade views, so
there are no Tailwind classes to compile.
Checking is not owning
This is the distinction the whole package is built around, and the one that is most expensive to get wrong.
| Checking a NOP | Registering a tax object | |
|---|---|---|
| Endpoint | POST /pbb/check |
POST /pbb/objects |
| Who may | anyone, any NOP | the citizen declaring it is theirs |
| What it means | nothing | a claim of ownership |
| Grounds to contact the citizen | no | yes |
The app deliberately invites people to check any NOP: a curious neighbour, a buyer doing due diligence, a child checking their parents' tax. The Cek Pajak screen even suggests asking the village office if you do not know the NOP.
So if the check log were used to infer ownership, some citizens would be questioned about land that has nothing to do with them. And once people learn that checking can get you a phone call, they stop checking. The feature dies, and the Finance Department loses the very data it wanted to collect.
That is why nawasara_pbb_checks is statistics only, and why a separate table
exists for declarations.
Endpoints
All behind ['api', 'api.citizen', 'throttle:nawasara-citizen'], under the
nawasara-api prefix (api/v1 by default).
POST /pbb/check
{ "nop": "35.02.020.022.004-0206.0" }
nop may be written with the separators or as 18 plain digits. The server adds
the separators before calling sipandaunik, so both forms work. Other codes:
404 NOP not found, 422 not 18 digits, 502 sipandaunik unreachable.
{
"data": {
"name_masked": "T••••••",
"bills": [
{ "year": 2026, "status": "LUNAS", "principal": 13133, "penalty": 0, "amount": 13133 }
],
"total_due": 0,
"has_arrears": false
}
}
GET /pbb/objects
The objects the logged-in citizen has registered. No pagination: the list is capped per citizen, and a dozen rows are better sent whole.
{
"data": [
{
"id": "01996f2c-…",
"nop": "35.02.010.004.002-0157.0",
"relation": "owner",
"name_masked": "T••••••",
"total_due": 184000,
"has_arrears": false,
"registered_at": "2026-09-20T08:14:00+07:00"
}
]
}
total_due and has_arrears may be null: the server does not refresh them on
every read.
POST /pbb/objects
{ "nop": "35.02.010.004.002-0157.0", "relation": "owner" }
relation is owner, heir or authorized, and it is what the citizen
declares, not what the SPPT says. The NOP is verified to exist through
sipandaunik before anything is stored. Returns 201 with the same shape as one
item of GET /pbb/objects.
| Code | Meaning |
|---|---|
404 |
the NOP does not exist in sipandaunik |
409 |
this citizen already registered this NOP |
422 |
not 18 digits, relation outside the three values, or the per-citizen cap is reached |
502 |
sipandaunik unreachable |
A repeat registration gets 409 rather than silently overwriting. Someone
registering the same NOP twice is confused, not updating, and saying nothing
makes them press the button again.
DELETE /pbb/objects/{id}
Withdraws the declaration, 204 on success, 404 if the object is not the
caller's. It is a soft delete; see below.
Design notes
POST rather than GET for a read. A NOP points to a specific plot of land, and putting it in a query string records it in access logs and proxy history. A POST body does not.
The name is shortened to its first letter and the address is removed.
sipandaunik returns the full nama_wp, alamat_wp, and alamat_op to anyone
who knows a NOP, with no session check at all. We tested this on 12 September
2026: the request returns the same full data with no cookie and with a garbage
cookie. Because a neighbour's NOP is easy to guess (they run in sequence within
a block), showing the full name would leak who owns what. An initial is enough
for a citizen to confirm "this is mine". The property address never leaves the
system, because it is the field that points to the physical location. This holds
for registered objects too: registering something does not unlock its address.
Filtering lives in the Resource, not the client. PbbBillResource is
written as an allow list, so if SipandaunikClient ever returns a new field it
is not forwarded by accident. Masking in the client would leave paths that
forget to filter.
sipandaunik needs the separators, and fails in a disguised way without
them. It splits the NOP on its dots and dashes; given 18 plain digits,
helpers/nop_helper.php throws Undefined offset: 1. The reply is HTTP 200
with about 5 KB of HTML error ending in {"status":false}, so the body is not
valid JSON at all. Before v0.2.0 that fell through to the "not found" branch and
the citizen was told their correct number did not exist. Two fixes, and both
matter: the client now adds the separators itself (normalizeNop), and a reply
that cannot be parsed, or that has no status key, is treated as an upstream
failure. Reported by the app team on 14 September 2026.
A 502 is kept separate from a 404. sipandaunik being down and a NOP not existing are two different things, and treating them the same would make a citizen think their tax is paid when the system is actually unreachable.
Recording a check never fails a check. CheckRecorder::record() does not
throw. The lookup is a service to citizens; the log is an internal need, and the
second must not cost the first. A failed write only goes to the log.
Only checks that reach sipandaunik are recorded. A 422 (not 18 digits) or
502 (upstream down) says nothing about how many citizens checked their bill.
A 404 is recorded, with found = false, precisely so guessing at NOPs can be
separated from genuine lookups rather than inflating the same number.
One NOP can be registered by several citizens. The unique key is
(citizen_sub, nop), not nop. Inherited land handled by three siblings gets
registered three times, each in their own app, and that is normal. Limiting a
NOP to one citizen would reject the second sibling for a reason they cannot
understand, and inherited plots are exactly the ones nobody is paying for. For
the Finance Department, several names on one plot is not a problem: it means
more people who can be asked.
A name that does not match is not a reason to reject. The most common case
is inherited land where the title has not been transferred, or a purchase where
the certificate has not moved yet. Rejecting those would exclude precisely the
plots that most need registering. The system has nothing to compare against
anyway: a citizen's NIK cannot be matched to the name on the SPPT, and
/pbb/check only returns an initial. So this records the citizen's declaration
as given; it does not verify it.
Withdrawn declarations are soft-deleted. A declaration that was once made still means something to the Finance Department even after it is withdrawn, and deleting it permanently destroys a trace that may still be needed. Retention is a UU 27/2022 PDP question and is still with the Finance Department; a soft delete keeps both options open, a hard delete does not. Registering a withdrawn NOP again restores the same row rather than creating a second one, which the unique key requires and which is what the citizen sees anyway.
There is a cap of 20 objects per citizen. Not because anyone truly owns
twenty plots, but because one account should not be able to register hundreds of
NOPs when each registration calls sipandaunik. The number is in the 422 body
so the app can tell the citizen rather than just showing an error. Withdrawn
objects do not count towards it.
Tables
| Table | Key | Holds |
|---|---|---|
nawasara_pbb_checks |
bigint | citizen_sub, nop, found, total_due, checked_at |
nawasara_pbb_objects |
uuid | citizen_sub, nop, relation, name_masked, cached totals, registered_at, soft delete |
Checks use a bigint key because nothing outside the system ever refers to a row:
no endpoint returns its id. Objects use a UUID because the id does go out to the
app and is used in DELETE /pbb/objects/{id}.
Roadmap
A web panel for the Finance Department to search "who registered this NOP" is not built. It would be a separate endpoint with its own authorisation, and it should not be mixed into the citizen routes.
Refreshing total_due on the object list is done at registration only. Doing it
on every read would call sipandaunik once per row, which is why the field is
documented as possibly stale.
Author
Pringgo J. Saputro, Kominfo Ponorogo. MIT License.