Technical Documentation

ParkSwift POS is a mobile-first parking ticketing system for Android handheld terminals with built-in thermal printers. This page documents the stack, architecture, and internal conventions for whoever maintains or extends it next.

Overview

An attendant opens the app on an Android PDA, creates a ticket at vehicle entry (plate number optional, vehicle type required), and the receipt prints automatically via the device's built-in print service. At exit, the attendant scans or looks up the ticket, the fee is calculated, and a second receipt prints. Tickets are stored locally on-device first and synced to a central SQLite database, so a terminal keeps working through a dropped connection.

Two hidden, unlinked pages sit alongside the attendant flow: an Executive Dashboard for revenue and activity reporting, and an Admin Maintenance page for correcting or removing bad records. Both are reachable only by URL — see Routes Reference.

Tech Stack

ASP.NET Core MVC (.NET 8) Application framework — no API controllers, no SPA framework
Dapper Micro-ORM — raw inline SQL, no EF Core, no repository abstraction
Microsoft.Data.Sqlite Storage — single-file parkswift.db, no separate DB server
Razor Views (.cshtml) Server-rendered HTML, no client-side templating
Bootstrap 5 Base styling for attendant-facing screens, navy/gold theme
Vanilla JavaScript No framework — per-page .js files, IndexedDB via Dexie-style localStorage queue for offline tickets
Chart.js 4 Dashboard's Daily Park OUT line chart
SweetAlert2 All confirm/alert/prompt dialogs app-wide — no native alert()/confirm()
davidshimjs/qrcodejs + jsQR Client-side QR generation (receipt) and scanning (exit lookup), bundled locally — no CDN dependency, works offline
Service Worker Offline shell caching for the attendant PWA experience
IIS + ASP.NET Core Module Production hosting, InProcess model, behind HTTPS binding
Why no ORM / SPA framework? Deliberate — every project in this suite uses raw Dapper SQL and server-rendered MVC. It keeps the stack small, avoids build tooling on top of the app, and means any page can be read top-to-bottom without chasing abstractions.

Project Structure

ParkSwiftPOS/ ├── # Request handling │  Controllers/ │  ├── TicketController.cs  # Entry/Exit/Receipt + offline sync endpoint │  ├── ReportController.cs  # Hidden Executive Dashboard │  ├── AdminController.cs   # Hidden Admin Maintenance (edit/delete) │  └── HomeController.cs    # Error page, Documentation, Manual │ │  # Data │  Models/ │  ├── Ticket.cs          # POCO — one parking ticket │  └── TicketStore.cs      # Dapper/SQLite data access, singleton │ │  # Views — one folder per controller, plus shared partials │  Views/ │  ├── Ticket/          # Entry, Exit, Receipt, Scanning, Summary │  ├── Report/          # Dashboard.cshtml (Layout = null) │  ├── Admin/           # Maintenance.cshtml (Layout = null) │  ├── Home/            # Error, Documentation, Manual (this page) │  └── Shared/_Layout.cshtml # Attendant nav shell (Ticket/* views only) │ │  # Static assets — strict per-page CSS/JS separation, no inline <style>/<script> │  wwwroot/ │  ├── css/  site.css · report.css · admin.css · docs.css │  ├── js/   site.js · activation.js · offline-tickets.js · qrcode.js · jsQR.js │  └── service-worker.js │ │  App_Data/parkswift.db   # SQLite file, created at runtime — not in source control │ ├── CLAUDE.md            # Architectural constraints & conventions for maintainers ├── proposal-sqlite-sync.md # Design doc for the multi-device sync model └── Program.cs           # DI, middleware, routing

Views are grouped by controller, and each hidden/internal page (Report, Admin, and this Home/Documentation / Home/Manual pair) sets Layout = null and owns its full HTML document — they intentionally never load _Layout.cshtml, so the attendant nav and activation.js device-activation prompt never fire on them.

Architecture

The whole app is one ASP.NET Core MVC project — no separate API layer. Controllers call straight into TicketStore, which wraps Dapper queries against a single SQLite file. There is one shared TicketStore instance (registered as a singleton in Program.cs), so every controller sees the same data without an extra service layer in between.

Why offline-first

Attendant terminals are handheld Android devices that may lose signal in a parking structure. offline-tickets.js writes every ticket to localStorage immediately at creation — the server round-trip is not on the critical path for printing a ticket. A background sync (manual "Sync" action or periodic retry) later pushes queued tickets to TicketController's sync endpoint, which upserts them into SQLite.

Identity model

Two identifiers matter more than the human-facing ticket number:

  • DeviceId — a GUID generated once per device and kept in that device's localStorage. Identifies which terminal created a ticket, independent of anything a human names it.
  • ClientTicketId — a GUID generated on-device at ticket creation. This is the real sync identity: stable, globally unique, and what sync upserts key on — so a retried sync request or a second sync after exit updates the same row instead of duplicating it.

TicketNumber (YY-MMdd-####) stays purely a human-facing display/print value. It is not relied on for uniqueness — two devices can legitimately print the same ticket number on the same day without conflict, since each has a distinct ClientTicketId underneath.

Process Flow

Entry → Exit → Receipt

Attendant (Entry.cshtml) │ select vehicle type, optional plate number ▼ Client generates ClientTicketId (GUID) + TicketNumber (YY-MMdd-####) │ saved to localStorage immediately ▼ Receipt.cshtml auto-prints via window.print() │ (routes to the device's OS-level 58mm thermal print service) ▼ Vehicle exits — attendant opens Exit.cshtml │ scans QR (Scanning.cshtml, jsQR) or looks up by ticket number ▼ Fee calculated (vehicle-tiered hourly rate, rounds up) → ExitTime + Fee set ▼ Second receipt prints — ticket marked closed locally

Offline Sync

offline-tickets.js (per device) │ queued tickets in localStorage (open + closed) ▼ syncNow() → POST TicketController sync endpoint │ payload: ClientTicketId, DeviceId, TicketNumber, │ PlateNumber, VehicleType, EntryTime, ExitTime, Fee ▼ TicketStore.ImportTicket() │ INSERT … ON CONFLICT(ClientTicketId) DO UPDATE │ (idempotent — safe to retry, safe to sync twice) ▼ SQLite Tickets table (single source of truth once synced)

Reporting & Admin

Executive Dashboard (/system-report-x9k2) │ GetPaidTickets() / GetOpenTickets() — filterable by │ device, vehicle type, date range ▼ KPI totals, income-by-device table, 14-day chart, recent logs Admin Maintenance (/system-admin-t4v8) │ GetAllTickets() — same filters, no date cap on state ▼ Inline edit → UpdateTicket() Row delete → DeleteTicket() Bulk delete → DeleteTickets(ids) Delete-by-device → DeleteTicketsByDevice() (no create action anywhere)

Data Model

Tickets table

ColumnTypeNotes
IdINTEGER PKServer-side surrogate key
ClientTicketIdTEXT, UNIQUESync identity — GUID from the originating device
DeviceIdTEXTWhich terminal created the ticket
TicketNumberTEXTDisplay/print value only, not unique
PlateNumberTEXT, nullableOptional at entry
VehicleTypeTEXT, default AllAll / Ebike / Motorcycle / Car / Truck
EntryTimeTEXT (ISO 8601)Required
ExitTimeTEXT (ISO 8601), nullableNull = still parked
FeeREAL, nullableSet at exit
SyncedAtTEXT (ISO 8601)Server-side timestamp of last import/update

Devices table

ColumnTypeNotes
DeviceIdTEXT PKMatches Tickets.DeviceId
NameTEXTAdmin-assigned friendly label, e.g. "Gate 1 - North Entrance"
UpdatedAtTEXT (ISO 8601)Last rename timestamp
A device with no row in Devices simply shows its truncated GUID everywhere in the UI until someone names it via the "Name Device" control on either hidden page.

Routes Reference

RoutePurposeVisibility
/Ticket/EntryCreate ticket, print receiptAttendant nav
/Ticket/ExitScan/lookup, close ticket, print receiptAttendant nav
/Ticket/SummaryDaily Report (attendant-facing totals)Attendant nav
/system-report-x9k2Executive Dashboard — KPIs, chart, device renameHidden, unlinked
/system-admin-t4v8Admin Maintenance — edit/delete tickets, bulk delete, delete-by-deviceHidden, unlinked
/Home/DocumentationThis pageUnlinked
/Home/ManualUser manualUnlinked
"Hidden" means unlinked, not secured. Neither /system-report-x9k2 nor /system-admin-t4v8 has a password, IP restriction, or auth check — anyone with the exact URL gets straight in. If either URL ever leaks, swap the route segment in the relevant controller for a new unguessable value, or add real authentication.

Deployment (IIS)

  • Hosting model: InProcess — avoids the extra reverse-proxy hop to Kestrel under IIS.
  • Forwarded headers: wired up in Program.cs so IIS/ARR's forwarded scheme/host are respected — required for UseHttpsRedirection() and correct URL generation behind an HTTPS binding.
  • Globalization: InvariantGlobalization is false so the ₱ symbol formats correctly — confirm ICU data is present on the target Windows Server (present by default on full Server, unlike minimal/Nano containers).
  • Publish: dotnet publish -c Release -o ./publish, then point an IIS site (ASP.NET Core Module / Hosting Bundle installed) at the publish folder. web.config is auto-generated.
  • HTTPS: bind the IIS site to a real or internally-trusted certificate — self-signed dev certs are local-testing only.
  • Data persistence: App_Data/parkswift.db is created at runtime, not part of the publish output — a full folder wipe/mirror-style deploy (Web Deploy sync, robocopy /MIR) can delete it. Point ConnectionStrings:Sqlite outside the deploy folder in appsettings.Production.json for a safer setup.

Known Limitations

  • No auth. No attendant login or shift binding — every action is anonymous, and neither hidden page is access-controlled beyond URL secrecy.
  • No CSRF/antiforgery tokens on POST actions yet — add before this runs over an untrusted/public network.
  • Vehicle-tiered hourly rate — Ebike/Motorcycle ₱30, Car/Truck/All ₱50; 1~2 hours flat at base rate, then +base rate per additional hour, no day-cap tier. No configurable rate-rule engine yet.
  • Print confirmation is neutral. The browser doesn't report whether the attendant actually printed or cancelled the dialog — "sent to printer" is not a guarantee of a physical print.
  • Sequential integer ticket IDs are exposed in admin URLs/payloads — not yet the no-exposed-IDs routing convention used elsewhere in the suite.

Roadmap

  1. Attendant login + shift binding, for per-attendant traceability.
  2. Antiforgery tokens on all POST actions.
  3. Real rate-rule engine (flat / hourly / day-cap).
  4. Confirm the built-in printer's Android print-service driver handles 58mm paper width/margins by default (some OEM services default to A4/Letter).
  5. Tighten admin/report routing away from sequential integer IDs.