/** * ParkSmarterClient — a typed wrapper over the ParkSmarter (IPS Group) REST API. * * Reverse-engineered from the official Android app (com.ipsgroupinc.parksmarter 4.4.0, * a React Native / Hermes build). All endpoint paths, header names, the app-token * scheme, and request/response field names were recovered from the app bundle. * * Auth model (NOT OAuth bearer): * - `Application_Token` : identifies the app build; required on every call. * - `Auth_Token` : the logged-in user's token; returned by POST /api/Auth * (login) in the body field `Auth_Token`, then sent as a * header on authenticated calls. * - `ParkSmarter_SessionId` : a server session id; seeded by * GET /api/ApplicationValidity and login, echoed on some * responses. Sent as a header when present. * - `X-Request-Id` : a fresh UUID per request. * * The client persists Auth_Token and SessionId into the provided TokenStore * automatically after login / bootstrap. */ import { DEFAULT_ENVIRONMENT, ENVIRONMENTS, Environment, EnvironmentName, } from './environments.js'; import { HttpClient, MemoryTokenStore, ParkSmarterResponse, TokenStore, } from './http.js'; import * as T from './types.js'; export interface ParkSmarterClientOptions { /** Named environment (default 'prodv2') or a fully custom Environment. */ environment?: EnvironmentName | Environment; /** Token persistence. Defaults to in-memory. Use secure storage in real apps. */ tokens?: TokenStore; /** Locale sent as the `localeCode` query param (default 'en'). */ localeCode?: string; timeoutMs?: number; /** Provide a fetch impl for React Native / Node < 18 / tests. */ fetchImpl?: typeof fetch; uuid?: () => string; /** Called when any authenticated request returns 401 (token cleared automatically). */ onUnauthorized?: () => void; /** Log every request/response (redacted) — useful for on-device debugging via logcat. */ logRequests?: boolean; /** Where log lines go (default console.log). */ logSink?: (line: string) => void; } /** Thrown when credentials are rejected (the server signals this via a 200 + Status:"Error"). */ export class LoginError extends Error { constructor(message: string) { super(message); this.name = 'LoginError'; } } function resolveEnvironment( env: EnvironmentName | Environment | undefined, ): Environment { if (!env) return ENVIRONMENTS[DEFAULT_ENVIRONMENT]; if (typeof env === 'string') return ENVIRONMENTS[env]; return env; } export class ParkSmarterClient { readonly http: HttpClient; readonly tokens: TokenStore; constructor(options: ParkSmarterClientOptions = {}) { this.tokens = options.tokens ?? new MemoryTokenStore(); this.http = new HttpClient({ environment: resolveEnvironment(options.environment), tokens: this.tokens, localeCode: options.localeCode, timeoutMs: options.timeoutMs, fetchImpl: options.fetchImpl, uuid: options.uuid, onUnauthorized: options.onUnauthorized, logRequests: options.logRequests, logSink: options.logSink, }); } /** Set the locale used for the `localeCode` query param on subsequent calls. */ setLocale(localeCode: string): void { this.http.setLocaleCode(localeCode); } /** Toggle redacted request/response logging at runtime. */ setLogRequests(on: boolean): void { this.http.setLogRequests(on); } private unwrap(p: Promise>): Promise { return p.then((r) => r.data); } /* ============================================================== */ /* Bootstrap / app validity */ /* ============================================================== */ /** * GET /api/ApplicationValidity — call this first. Returns feature flags, * maintenance/upgrade status, and a SessionId that is stored automatically. * Public (no Auth_Token required). */ async getApplicationValidity(): Promise { const res = await this.http.request({ method: 'GET', path: '/api/ApplicationValidity', query: { ApplicationToken: this.http.environment.appToken }, includeAuthToken: false, }); return res.data; } /* ============================================================== */ /* Auth */ /* ============================================================== */ /** * The server returns HTTP 200 even for a FAILED login, signalling the failure * only via `Status: "Error"` + a null `Auth_Token`. Detect that here so callers * get a real error instead of a phantom "signed-in" state. */ private finishLogin(data: T.AuthResponse): Promise { const token = typeof data?.Auth_Token === 'string' ? data.Auth_Token : ''; if (!token || data?.Status === 'Error') { throw new LoginError(data?.Message || 'Invalid login or password.'); } return this.tokens.setAuthToken ? Promise.resolve(this.tokens.setAuthToken(token)).then(() => data) : Promise.resolve(data); } /** POST /api/Auth — phone + password login. Persists Auth_Token & SessionId. */ async loginWithPhone(params: T.LoginWithPhoneParams): Promise { const res = await this.http.request({ method: 'POST', path: '/api/Auth', body: { UserName: params.phoneNumber, Password: params.password }, includeAuthToken: false, }); return this.finishLogin(res.data); } /** POST /api/Auth — Sign in with Apple. Persists Auth_Token & SessionId. */ async loginWithApple(params: T.LoginWithAppleParams): Promise { const res = await this.http.request({ method: 'POST', path: '/api/Auth', body: { EmailAddress: params.emailAddress, AppleUserId: params.appleId, ProviderAuth: params.providerAuth, ProviderIdentity: params.providerIdentity, }, includeAuthToken: false, }); return this.finishLogin(res.data); } /** Re-authenticate using a cached Auth_Token (sets it, then callers can bootstrap). */ async loginWithCachedToken(authToken: string): Promise { await this.tokens.setAuthToken(authToken); } /** POST /api/Auth/Logout — invalidate the token on all devices. */ async logoutAllDevices(): Promise { await this.http.request({ method: 'POST', path: '/api/Auth/Logout' }); } /** Clear local tokens (client-side sign out). */ async logoutLocal(): Promise { await this.tokens.setAuthToken(null); await this.tokens.setSessionId(null); } /* ============================================================== */ /* Sign up, password, verification */ /* ============================================================== */ /** POST /api/User — create an account. Public. */ async signUp(params: T.SignUpParams): Promise { await this.http.request({ method: 'POST', path: '/api/User', body: { PersonalEmailAddress: params.emailAddress, PersonalPhone: params.mobilePhone, Password: params.password, }, includeAuthToken: false, }); return true; } /** POST /api/Password — request a password reset. Public. */ async requestResetPassword( params: T.RequestResetPasswordParams, ): Promise { await this.http.request({ method: 'POST', path: '/api/Password', body: { ResetPasswordType: params.reqType, PersonalEmailAddress: '', PersonalPhone: params.phoneNumber, }, includeAuthToken: false, }); } /** PUT /api/Password — change password (authenticated). */ async updatePassword(params: T.UpdatePasswordParams): Promise { await this.http.request({ method: 'PUT', path: '/api/Password', body: { OldPassword: params.oldPassword, NewPassword: params.newPassword }, }); } /** POST /api/UserVerification — request an SMS verification code. */ async requestVerifyUser(params: T.RequestVerifyUserParams): Promise { await this.http.request({ method: 'POST', path: '/api/UserVerification', body: { PhoneNumber: '1' + params.phoneNumber }, }); } /** GET /api/UserVerification — confirm an SMS code. */ async verifyUser(params: T.VerifyUserParams): Promise { await this.http.request({ method: 'GET', path: '/api/UserVerification', query: { PhoneNumber: '1' + params.phoneNumber, ConfirmationCode: params.code, }, }); } /** GET /api/User?EmailAddress=… — is this email already registered? */ async isEmailRegistered(emailAddress: string): Promise { const res = await this.http.request({ method: 'GET', path: '/api/User', query: { EmailAddress: emailAddress }, includeAuthToken: false, }); return Boolean(res.data?.PersonalEmailAddress); } /** GET /api/User?PhoneNumber=… — is this phone already registered? */ async isPhoneRegistered(phoneNumber: string): Promise { const res = await this.http.request({ method: 'GET', path: '/api/User', query: { PhoneNumber: phoneNumber }, includeAuthToken: false, }); return Boolean(res.data?.PersonalPhone); } /* ============================================================== */ /* User profile */ /* ============================================================== */ /** GET /api/User — full profile incl. vehicles & cards (authenticated). */ getUserDetail(): Promise { return this.unwrap( this.http.request({ method: 'GET', path: '/api/User', query: { EmailAddress: '', PhoneNumber: '' }, }), ); } /** PUT /api/User — update email/phone (authenticated). */ async updateProfile(params: T.UpdateProfileParams): Promise { await this.http.request({ method: 'PUT', path: '/api/User', body: { PersonalEmailAddress: params.emailAddress, PersonalPhone: params.phoneNumber, }, }); } /** DELETE /api/User — request account deletion (authenticated). */ async requestDeleteUser(): Promise { await this.http.request({ method: 'DELETE', path: '/api/User', query: { DeviceType: 'Android' }, }); } /** PUT /api/Device — register/update the push device token. */ async updateDeviceToken( params: T.UpdateDeviceTokenParams, ): Promise { return this.unwrap( this.http.request({ method: 'PUT', path: '/api/Device', body: { DeviceID: params.pushNotificationsToken, IMEINumber: params.imeiNumber, DeviceType: params.deviceType ?? '1', Language: params.language, }, }), ); } /* ============================================================== */ /* Vehicles */ /* ============================================================== */ /** POST /api/Vehicle — add a vehicle. */ async addVehicle(params: T.AddVehicleParams): Promise { return this.unwrap( this.http.request({ method: 'POST', path: '/api/Vehicle', body: { VehiclePlate: params.plate, VehicleState: params.state, VehicleAlias: params.vehicleAlias, IsDefault: params.isDefaultVehicle ?? false, }, }), ); } /** PUT /api/Vehicle — update a vehicle. Success when `Status === 'Success'`. */ async updateVehicle( params: T.UpdateVehicleParams, ): Promise { return this.unwrap( this.http.request({ method: 'PUT', path: '/api/Vehicle', body: { VehiclePlate: params.plate, VehicleState: params.state, VehicleID: params.id, VehicleAlias: params.vehicleAlias, IsDefault: params.isDefaultVehicle ?? false, }, }), ); } /** DELETE /api/Vehicle?VehicleID=… */ async deleteVehicle(vehicleId: number | string): Promise { await this.http.request({ method: 'DELETE', path: '/api/Vehicle', query: { VehicleID: vehicleId }, }); } /* ============================================================== */ /* Credit cards */ /* ============================================================== */ /** POST /api/Card — add a card. */ async addCard(params: T.AddCardParams): Promise { return this.unwrap( this.http.request({ method: 'POST', path: '/api/Card', body: this.buildCardBody(params), }), ); } /** POST /api/Card — update a card (full replace). */ async updateCard(params: T.AddCardParams): Promise { return this.unwrap( this.http.request({ method: 'POST', path: '/api/Card', body: this.buildCardBody(params), }), ); } /** PUT /api/Card — set (or unset) a card as default. */ async setCardDefault(params: T.SetDefaultCardParams): Promise { return this.unwrap( this.http.request({ method: 'PUT', path: '/api/Card', body: { CCID: params.id, CCDefault: params.isDefaultCard }, }), ); } /** DELETE /api/Card?CCID=… */ async deleteCard(cardId: number | string): Promise { await this.http.request({ method: 'DELETE', path: '/api/Card', query: { CCID: cardId }, }); } private buildCardBody(p: T.AddCardParams) { return { CCNumber: p.cardNumber, CCAlias: p.alias, CCExpDate: p.expDate, CCZip: p.zipCode, CCDefault: p.isDefaultCard ?? false, ParentCCID: p.id, }; } /* ============================================================== */ /* Meters / zones */ /* ============================================================== */ /** GET /api/Meter?Lat=&Long= — meters near a coordinate. */ getMetersByLocation(loc: T.LatLng): Promise { return this.unwrap( this.http.request({ method: 'GET', path: '/api/Meter', query: { Lat: loc.latitude, Long: loc.longitude }, }), ); } /** GET /api/MeterList?Lat=&Long= — limited meter list near a coordinate. */ getLimitedMetersByLocation(loc: T.LatLng): Promise { return this.unwrap( this.http.request({ method: 'GET', path: '/api/MeterList', query: { Lat: loc.latitude, Long: loc.longitude }, }), ); } /** GET /api/Meter?ZoneName=… */ getMetersByZoneName(zoneName: string): Promise { return this.unwrap( this.http.request({ method: 'GET', path: '/api/Meter', query: { ZoneName: zoneName }, }), ); } /** GET /api/Meter?Query=… — zone or space name search. */ searchMetersByZoneOrSpace(query: string): Promise { return this.unwrap( this.http.request({ method: 'GET', path: '/api/Meter', query: { Query: query }, }), ); } /** GET /api/Meter?TerminalSerNo=… */ getMetersBySerialNumber(serialNumber: string): Promise { return this.unwrap( this.http.request({ method: 'GET', path: '/api/Meter', query: { TerminalSerNo: serialNumber }, }), ); } /** GET /api/Meter?ScannerCode=… (e.g. from a scanned QR/barcode). */ getMetersByScannerCode(scannerCode: string): Promise { return this.unwrap( this.http.request({ method: 'GET', path: '/api/Meter', query: { ScannerCode: scannerCode }, }), ); } /** GET /api/ParkingLogix — nearby parking lots with occupancy. */ getParkingLots(): Promise { return this.unwrap( this.http.request({ method: 'GET', path: '/api/ParkingLogix', }), ); } /* ============================================================== */ /* Estimates */ /* ============================================================== */ /** GET /api/ParkingEstimateMulti — multi-duration price ladder. */ getParkingEstimateMulti( params: T.MultiEstimateParams, ): Promise { const query: Record = { ZoneID: params.zoneId, SpaceID: params.spaceId, CustomerID: params.customerId, MinCreditAmount: params.minCreditAmount, VehicleID: params.vehicleId, }; if (params.bleEncBytes) query.BleEncBytesString = params.bleEncBytes; return this.unwrap( this.http.request({ method: 'GET', path: '/api/ParkingEstimateMulti', query, }), ); } /** GET /api/ParkingEstimate — single-duration price. */ getParkingEstimateSingle( params: T.SingleEstimateParams, ): Promise { return this.unwrap( this.http.request({ method: 'GET', path: '/api/ParkingEstimate', query: { ZoneID: params.zoneId, SpaceID: params.spaceId, CustomerID: params.customerId, ParkingDuration: params.durationInMinutes, VehicleID: params.vehicleId, CCID: params.creditCardId, }, }), ); } /** GET /api/ParkingEstimateItems — item-based price options. */ getParkingEstimateItems( params: T.ItemsEstimateParams, ): Promise { return this.unwrap( this.http.request({ method: 'GET', path: '/api/ParkingEstimateItems', query: { ZoneID: params.zoneId, SpaceID: params.spaceId, CustomerID: params.customerId, VehicleID: params.vehicleId, }, }), ); } /* ============================================================== */ /* Parking sessions */ /* ============================================================== */ /** POST /api/Session — start/pay for a parking session. */ startParkingSession( params: T.StartParkingSessionParams, ): Promise { const fee = params.transactionFee ?? 0; const body: Record = { CCID: String(params.creditCardId), Amount: (params.parkingCost + fee).toFixed(2), SpaceID: params.spaceId, StartTime: params.startTime, EndTime: params.endTime, CustomerID: params.customerId, VehicleID: String(params.vehicleId), TimePurchased: String(params.minutesToPurchase), ParkingCost: params.parkingCost.toFixed(2), TransactionFee: fee.toFixed(2), ZoneID: params.zoneId, MinCreditAmount: params.minCreditAmount != null ? params.minCreditAmount.toFixed(2) : undefined, MeterTypeId: params.meterTypeId, }; if (params.bleEncBytes) body.BleEncBytes = params.bleEncBytes; return this.unwrap( this.http.request({ method: 'POST', path: '/api/Session', body, }), ); } /** GET /api/ParkingSession — currently active sessions. */ getActiveParkingSessions(): Promise { return this.unwrap( this.http.request({ method: 'GET', path: '/api/ParkingSession', }), ); } /** GET /api/Session — past sessions (paged). */ getPastParkingSessions( params: T.PastSessionsParams = {}, ): Promise { return this.unwrap( this.http.request({ method: 'GET', path: '/api/Session', query: { CurrentPage: params.currentPage ?? '', PageSize: params.pageSize ?? '', }, }), ); } /* ============================================================== */ /* Receipts */ /* ============================================================== */ /** GET /api/ParkingReceipt?TransactionID=… */ getParkingReceipt(transactionId: number | string): Promise { return this.unwrap( this.http.request({ method: 'GET', path: '/api/ParkingReceipt', query: { TransactionID: transactionId }, }), ); } /** POST /api/ParkingReceipt?Id=… — email a receipt to the account holder. */ async emailParkingReceipt(receiptId: number | string): Promise { await this.http.request({ method: 'POST', path: '/api/ParkingReceipt', query: { Id: receiptId }, }); } /* ============================================================== */ /* Notification settings */ /* ============================================================== */ /** GET /api/Setting — raw notification settings bitmask. */ getNotificationSettings(): Promise { return this.unwrap( this.http.request({ method: 'GET', path: '/api/Setting', }), ); } /** * POST /api/Setting — update notification settings. * `userSettings` is the numeric bitmask (as a string) the app computes via reduceSettings. */ async setNotificationSettings(userSettings: string | number): Promise { await this.http.request({ method: 'POST', path: '/api/Setting', body: { UserSettings: String(userSettings) }, }); } /* ============================================================== */ /* Static content */ /* ============================================================== */ /** * GET /api/State?ID=… — states/regions under a parent id. * The `ID` param is required — the route 404s without it (confirmed against prod). */ getStates(id: number | string): Promise { return this.unwrap( this.http.request({ method: 'GET', path: '/api/State', query: { ID: id }, includeAuthToken: false, }), ); } /** GET /api/ParkSmarterAbout — `{ Value }`. */ getAbout(): Promise { return this.unwrap( this.http.request({ method: 'GET', path: '/api/ParkSmarterAbout', includeAuthToken: false, }), ); } /** GET /api/ParkSmarterFAQ — `{ FAQs: [...] }`. */ getFAQ(): Promise { return this.unwrap( this.http.request({ method: 'GET', path: '/api/ParkSmarterFAQ', includeAuthToken: false, }), ); } /** GET /api/ParkSmarterPrivacyPolicies — `{ Value }`. */ getPrivacyPolicy(): Promise { return this.unwrap( this.http.request({ method: 'GET', path: '/api/ParkSmarterPrivacyPolicies', includeAuthToken: false, }), ); } /** GET /api/ParkSmarterTerms — `{ Value }`. */ getTerms(): Promise { return this.unwrap( this.http.request({ method: 'GET', path: '/api/ParkSmarterTerms', includeAuthToken: false, }), ); } }