Search by

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.

Package info

github.com/nawasara/pbb

pkg:composer/nawasara/pbb

Statistics

Installs: 14

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.0 2026-09-20 08:25 UTC

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.