Resources¶
Resources are the primary interface for interacting with the Sonny's Data API.
Each resource is accessed as a property on the SonnysClient and
exposes methods like list(), get(), and resource-specific operations.
with SonnysClient(api_id, api_key) as client:
customers = client.customers.list()
customer = client.customers.get("12345")
Base Classes¶
BaseResource ¶
BaseResource(client: SonnysClient)
Base class for all API resources.
Stores a reference to the parent :class:SonnysClient so that
subclasses can issue HTTP requests via self._client._request().
ListableResource ¶
ListableResource(client: SonnysClient)
Bases: BaseResource
Mixin for resources that support a paginated (or non-paginated) list endpoint.
Subclasses must define the following class attributes:
_path: URL path for the list endpoint (e.g.,"/customer")._items_key: Key insidedatathat holds the items array (e.g.,"customers")._model: Pydantic model class to validate each item against._default_limit: Page size for paginated requests (default100)._paginated: Whether the endpoint supports offset/limit pagination (defaultTrue). Set toFalsefor endpoints like/sitethat return all records in a single response.
list ¶
list(**params: object) -> list[SonnysModel]
Fetch all items from the list endpoint.
For paginated endpoints, automatically pages through all results using offset-based pagination (offset starts at 1 per API spec).
For non-paginated endpoints (_paginated=False), makes a single
request and returns all items.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
**params
|
object
|
Extra query parameters forwarded to every request
(e.g., |
{}
|
Returns:
| Type | Description |
|---|---|
list[SonnysModel]
|
A list of validated Pydantic model instances. |
GettableResource ¶
GettableResource(client: SonnysClient)
Bases: BaseResource
Mixin for resources that support a detail (get-by-ID) endpoint.
Subclasses must define the following class attributes:
_detail_path: URL path template with{id}placeholder (e.g.,"/customer/{id}")._detail_model: Pydantic model class to validate the detail response against.
get ¶
get(id: str) -> SonnysModel
Fetch a single resource by its ID.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
id
|
str
|
The resource identifier, substituted into |
required |
Returns:
| Type | Description |
|---|---|
SonnysModel
|
A validated Pydantic model instance. |
Concrete Resources¶
Customers ¶
Customers(client: SonnysClient)
Bases: ListableResource, GettableResource
Access the /customer list and detail endpoints.
Provides paginated customer search and individual customer lookup.
list()returns :class:~sonnys_data_client.types.CustomerListItemsummaries. SupportsstartDate,endDate,site, andregionfilters.get(id)returns a full :class:~sonnys_data_client.types.Customerprofile including address, contact info, and SMS preferences.
Items ¶
Items(client: SonnysClient)
Bases: ListableResource
Access the /item list endpoint.
Provides paginated item catalog listing. List-only resource with no detail endpoint.
list()returns :class:~sonnys_data_client.types.Itemrecords with SKU, name, department, and pricing info. Supportssitefilter.
Employees ¶
Employees(client: SonnysClient)
Bases: ListableResource, GettableResource
Access the /employee list and detail endpoints.
Provides paginated employee search, individual employee lookup, and time-tracking data.
list()returns :class:~sonnys_data_client.types.EmployeeListItemsummaries. SupportsstartDateandendDatefilters.get(id)returns a full :class:~sonnys_data_client.types.Employeerecord with contact info and employment dates.get_clock_entries(id)fetches :class:~sonnys_data_client.types.ClockEntrytime-tracking records for a specific employee.
get_clock_entries ¶
get_clock_entries(
employee_id: int | str,
*,
start_date: str | None = None,
end_date: str | None = None,
) -> list[ClockEntry]
Fetch clock entries for an employee.
The API returns a nested data.weeks[] structure where each week
contains a clockEntries[] array. This method flattens them into
a single list.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
employee_id
|
int | str
|
The employee identifier. |
required |
start_date
|
str | None
|
Optional start date filter (passed as |
None
|
end_date
|
str | None
|
Optional end date filter (passed as |
None
|
Returns:
| Type | Description |
|---|---|
list[ClockEntry]
|
A flat list of validated :class: |
Sites ¶
Sites(client: SonnysClient)
Bases: ListableResource
Access the /site/list endpoint.
Provides a complete site listing. Non-paginated resource that returns all sites in a single call. No detail endpoint.
list()returns :class:~sonnys_data_client.types.Siterecords with site ID, code, name, and timezone.
Giftcards ¶
Giftcards(client: SonnysClient)
Bases: ListableResource
Access the /giftcard-liablilty list endpoint.
Provides paginated gift card liability listing. List-only resource with no detail endpoint.
list()returns :class:~sonnys_data_client.types.GiftcardListItemrecords with balance and usage info.
Note
The API path contains a typo (giftcard-liablilty) which is
intentional to match the actual Sonny's API endpoint.
Washbooks ¶
Washbooks(client: SonnysClient)
Bases: ListableResource, GettableResource
Access the /washbook/account list and detail endpoints.
Provides paginated washbook account search and individual account lookup with full detail.
list()returns :class:~sonnys_data_client.types.WashbookListItemsummaries. Supports date and site filters.get(id)returns a full :class:~sonnys_data_client.types.Washbookrecord with customer, vehicle, and tag details.
RecurringAccounts ¶
RecurringAccounts(client: SonnysClient)
Bases: ListableResource, GettableResource
Access the /recurring/account list, detail, and custom endpoints.
The most feature-rich resource, providing paginated account search, individual account lookup, and specialized reporting endpoints.
list()returns :class:~sonnys_data_client.types.RecurringListItemsummaries with status and billing site info.get(id)returns a full :class:~sonnys_data_client.types.Recurringrecord with billing history, tags, vehicles, and customer details.list_status_changes()fetches status transition history.list_modifications()fetches account modification audit logs.list_details()fetches all accounts with full detail in bulk.
list_status_changes ¶
list_status_changes(
**params: object,
) -> list[RecurringStatusChange]
Fetch all recurring account status changes.
Returns:
| Type | Description |
|---|---|
list[RecurringStatusChange]
|
A flat list of :class: |
list_modifications ¶
list_modifications(
**params: object,
) -> list[RecurringModification]
Fetch all recurring account modifications.
Returns:
| Type | Description |
|---|---|
list[RecurringModification]
|
A flat list of :class: |
list_details ¶
list_details(**params: object) -> list[Recurring]
Fetch all recurring accounts with full detail.
Unlike :meth:list which returns summary :class:RecurringListItem
objects, this method returns full :class:Recurring detail objects.
Returns:
| Type | Description |
|---|---|
list[Recurring]
|
A flat list of :class: |
Transactions ¶
Transactions(client: SonnysClient)
Bases: ListableResource, GettableResource
Access the /transaction list, detail, and by-type endpoints.
The most complex resource, providing paginated transaction search, individual transaction lookup, type-filtered listing, and batch job support for large exports.
list()returns :class:~sonnys_data_client.types.TransactionListItemsummaries. SupportsstartDate,endDate,site,regionfilters.get(id)returns a full :class:~sonnys_data_client.types.Transactionrecord with line items, tenders, and discounts.list_by_type(type)filters transactions by type (wash, recurring, etc.).list_v2()uses the enriched v2 endpoint with customer and status fields.load_job()submits asynchronous batch jobs for large date ranges.
list ¶
list(**params: object) -> list[TransactionListItem]
Fetch all transactions, converting date strings to timestamps.
Accepts startDate / endDate as ISO-8601 strings
(e.g. "2026-01-15") or Unix timestamps.
Returns:
| Type | Description |
|---|---|
list[TransactionListItem]
|
A flat list of :class: |
list_by_type ¶
list_by_type(
item_type: str, **params: object
) -> list[TransactionListItem]
Fetch all transactions of a specific type.
Valid types include: wash, prepaid-wash, recurring, washbook, giftcard, merchandise, house-account. The API validates the type parameter.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
item_type
|
str
|
The transaction type to filter by. |
required |
**params
|
object
|
Extra query parameters forwarded to every request
(e.g., |
{}
|
Returns:
| Type | Description |
|---|---|
list[TransactionListItem]
|
A flat list of :class: |
list_v2 ¶
list_v2(**params: object) -> list[TransactionV2ListItem]
Fetch all transactions using the v2 endpoint.
The v2 endpoint returns enriched list items with customer_id,
is_recurring_plan_sale, is_recurring_plan_redemption, and
transaction_status fields.
Note: The API caches v2 responses for 10 minutes per reporting criteria.
Returns:
| Type | Description |
|---|---|
list[TransactionV2ListItem]
|
A flat list of :class: |
load_job ¶
load_job(
*,
poll_interval: float = 2.0,
timeout: float = 300.0,
**params: object,
) -> list[TransactionJobItem]
Submit batch jobs and auto-paginate through all results.
Pagination happens at the job submission level: each call to
/transaction/load-job with a different offset fetches
one page. The method submits as many jobs as needed to retrieve
all records.
Note: The API caches job data for 20 minutes and limits the date range to a maximum of 24 hours.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
poll_interval
|
float
|
Seconds between poll attempts (default 2.0). |
2.0
|
timeout
|
float
|
Max seconds to wait for each job (default 300.0). |
300.0
|
**params
|
object
|
Query parameters ( |
{}
|
Returns:
| Type | Description |
|---|---|
list[TransactionJobItem]
|
A list of :class: |
Raises:
| Type | Description |
|---|---|
APIError
|
If any job status is |
APITimeoutError
|
If any job does not complete within timeout. |
StatsResource ¶
StatsResource(client: SonnysClient)
Bases: BaseResource
Access computed business analytics and KPIs.
Unlike other resources that wrap REST endpoints directly,
StatsResource computes analytics by fetching raw data and
aggregating it locally. Individual stat methods (total sales,
total washes, conversion rate, etc.) will be added in Phases 21-25.
All stat methods accept a date range and delegate to
:meth:_resolve_dates for consistent parsing and validation.
retail_wash_count ¶
retail_wash_count(
start: str | datetime, end: str | datetime
) -> int
Count retail wash transactions for a date range.
A retail wash is a type=wash transaction (v1) that is neither
a recurring plan sale nor a recurring redemption (v2 flags).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
start
|
str | datetime
|
Range start as an ISO-8601 string (e.g. |
required |
end
|
str | datetime
|
Range end as an ISO-8601 string or
:class: |
required |
Returns:
| Type | Description |
|---|---|
int
|
The number of retail wash transactions in the date range. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If start is after end, or if a string cannot be parsed as a valid ISO-8601 date/datetime. |
Example::
count = client.stats.retail_wash_count("2026-01-01", "2026-01-31")
print(f"Retail washes: {count}")
new_memberships_sold ¶
new_memberships_sold(
start: str | datetime, end: str | datetime
) -> int
Count new membership sales for a date range.
Fetches enriched v2 transactions and identifies those flagged as
is_recurring_plan_sale, then verifies each via the v1 detail
endpoint to exclude plan upgrades/switches. Only transactions
where the v1 is_recurring_sale flag is True are counted.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
start
|
str | datetime
|
Range start as an ISO-8601 string (e.g. |
required |
end
|
str | datetime
|
Range end as an ISO-8601 string or
:class: |
required |
Returns:
| Type | Description |
|---|---|
int
|
The number of genuine new membership sales in the date range. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If start is after end, or if a string cannot be parsed as a valid ISO-8601 date/datetime. |
Example::
count = client.stats.new_memberships_sold("2026-01-01", "2026-01-31")
print(f"New memberships sold: {count}")
total_sales ¶
total_sales(
start: str | datetime, end: str | datetime
) -> SalesResult
Compute revenue breakdown for a date range.
Fetches all transactions via the enriched v2 endpoint and
categorizes them into three buckets: recurring plan sales,
recurring redemptions, and retail. Returns a
:class:~sonnys_data_client.types.SalesResult with per-bucket
breakdowns.
The total and count fields exclude membership
redemptions (is_recurring_plan_redemption=True), since
redemptions are $0-value usage events that do not generate
revenue. Redemption data is still available in the
recurring_redemptions and recurring_redemptions_count
breakdown fields.
.. note::
The total may be slightly higher than the Sonny's Back
Office Transaction Total due to customer overpayments. The
API includes overpaid amounts in transaction totals, while
Back Office tracks them as a separate line item. The typical
difference is <1%. Correcting for this would require
fetching full transaction details for every transaction to
compute the overpaid delta, which is not practical given the
API rate limit.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
start
|
str | datetime
|
Range start as an ISO-8601 string (e.g. |
required |
end
|
str | datetime
|
Range end as an ISO-8601 string or
:class: |
required |
Returns:
| Name | Type | Description |
|---|---|---|
A |
SalesResult
|
class: |
SalesResult
|
the grand total (excluding redemptions), transaction count, |
|
SalesResult
|
and per-category breakdowns. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If start is after end, or if a string cannot be parsed as a valid ISO-8601 date/datetime. |
Example::
result = client.stats.total_sales("2026-01-01", "2026-01-31")
print(f"Total: ${result.total:.2f}")
print(f"Memberships: ${result.recurring_plan_sales:.2f}")
total_washes ¶
total_washes(
start: str | datetime, end: str | datetime
) -> WashResult
Compute wash volume breakdown for a date range.
Fetches v2 transactions (for membership flags), v1 type=wash
(to identify car washes), and v1 type=recurring (to identify
recharges). Classification priority: redemption > plan sale >
wash > recharge > unknown.
eligible_wash_count is derived as
total - member_wash_count - free_wash_count.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
start
|
str | datetime
|
Range start as an ISO-8601 string (e.g. |
required |
end
|
str | datetime
|
Range end as an ISO-8601 string or
:class: |
required |
Returns:
| Name | Type | Description |
|---|---|---|
A |
WashResult
|
class: |
WashResult
|
total wash count and per-category breakdowns. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If start is after end, or if a string cannot be parsed as a valid ISO-8601 date/datetime. |
Example::
result = client.stats.total_washes("2026-01-01", "2026-01-31")
print(f"Total washes: {result.total}")
print(f"Retail: {result.retail_wash_count}")
print(f"Member: {result.member_wash_count}")
print(f"Eligible: {result.eligible_wash_count}")
print(f"Free: {result.free_wash_count}")
conversion_rate ¶
conversion_rate(
start: str | datetime, end: str | datetime
) -> ConversionResult
Compute the membership conversion rate for a date range.
Measures how effectively a site converts eligible wash customers
into membership sign-ups. The rate is computed as
new_memberships / eligible_washes.
Eligible washes are derived from the total wash count:
total_washes - member_washes - free_washes. This includes
retail washes with total > 0, plan sale washes, and unknown
non-negative transaction types. When there are zero eligible
washes the rate is 0.0 (division-by-zero safe).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
start
|
str | datetime
|
Range start as an ISO-8601 string (e.g. |
required |
end
|
str | datetime
|
Range end as an ISO-8601 string or
:class: |
required |
Returns:
| Name | Type | Description |
|---|---|---|
A |
ConversionResult
|
class: |
ConversionResult
|
the conversion rate and component counts. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If start is after end, or if a string cannot be parsed as a valid ISO-8601 date/datetime. |
Example::
result = client.stats.conversion_rate("2026-01-01", "2026-01-31")
print(f"Conversion rate: {result.rate:.1%}")
print(f"Memberships: {result.new_memberships}")
print(f"Eligible washes: {result.eligible_washes}")
total_labor_cost ¶
total_labor_cost(
start: str | datetime, end: str | datetime
) -> LaborCostResult
Compute labor cost breakdown for a date range.
Fetches all clock entries via
:meth:_fetch_all_clock_entries and aggregates regular and
overtime costs in a single pass. Each entry's cost is computed
as rate * hours for the corresponding pay type.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
start
|
str | datetime
|
Range start as an ISO-8601 string (e.g. |
required |
end
|
str | datetime
|
Range end as an ISO-8601 string or
:class: |
required |
Returns:
| Name | Type | Description |
|---|---|---|
A |
LaborCostResult
|
class: |
LaborCostResult
|
the total cost, regular/overtime breakdowns, hours, and entry |
|
LaborCostResult
|
count. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If start is after end, or if a string cannot be parsed as a valid ISO-8601 date/datetime. |
Example::
result = client.stats.total_labor_cost("2026-01-01", "2026-01-31")
print(f"Total: ${result.total_cost:.2f}")
print(f"Regular: ${result.regular_cost:.2f} ({result.regular_hours:.1f}h)")
print(f"Overtime: ${result.overtime_cost:.2f} ({result.overtime_hours:.1f}h)")
cost_per_car ¶
cost_per_car(
start: str | datetime, end: str | datetime
) -> CostPerCarResult
Compute labor cost per car for a date range.
Divides total labor cost by total wash volume to measure labor
efficiency. A value of 4.25 means the site spent $4.25 in
labor for each car washed. When there are zero washes the
result is 0.0 (division-by-zero safe).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
start
|
str | datetime
|
Range start as an ISO-8601 string (e.g. |
required |
end
|
str | datetime
|
Range end as an ISO-8601 string or
:class: |
required |
Returns:
| Name | Type | Description |
|---|---|---|
A |
CostPerCarResult
|
class: |
CostPerCarResult
|
the cost per car, total labor cost, and total wash count. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If start is after end, or if a string cannot be parsed as a valid ISO-8601 date/datetime. |
Example::
result = client.stats.cost_per_car("2026-01-01", "2026-01-31")
print(f"Cost per car: ${result.cost_per_car:.2f}")
print(f"Labor cost: ${result.total_labor_cost:.2f}")
print(f"Total washes: {result.total_washes}")
report ¶
report(
start: str | datetime, end: str | datetime
) -> StatsReport
Compute all KPIs for a date range in a single call.
Fetches v2 transactions, v1 type=wash, v1 type=recurring,
clock entries for all employees, and verifies each plan sale
candidate via get() to exclude plan upgrades/switches. Makes
4 bulk API calls (3 transaction + 1 employee list) plus
N_employees x ceil(days/14) clock-entry calls and
~N detail calls (one per v2 plan sale candidate, typically
~15/day), then computes every KPI locally.
The sales.total field excludes membership redemptions
to align with the Sonny's Back Office Transaction Total. See
:meth:total_sales for details.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
start
|
str | datetime
|
Range start as an ISO-8601 string (e.g. |
required |
end
|
str | datetime
|
Range end as an ISO-8601 string or
:class: |
required |
Returns:
| Name | Type | Description |
|---|---|---|
A |
StatsReport
|
class: |
StatsReport
|
|
|
StatsReport
|
|
|
StatsReport
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If start is after end, or if a string cannot be parsed as a valid ISO-8601 date/datetime. |
Example::
rpt = client.stats.report("2026-01-01", "2026-01-31")
print(f"Revenue: ${rpt.sales.total:.2f}")
print(f"Washes: {rpt.washes.total}")
print(f"New members: {rpt.new_memberships}")
print(f"Conversion: {rpt.conversion.rate:.1%}")
print(f"Labor cost: ${rpt.labor.total_cost:.2f}")
print(f"Cost per car: ${rpt.cost_per_car.cost_per_car:.2f}")
BackOfficeResource ¶
BackOfficeResource(client: SonnysClient)
Bases: BaseResource
Scraper for the BackOffice employee timesheets report.
Requires backoffice_username and backoffice_password at
:class:SonnysClient construction. The api_id doubles as the
BackOffice subdomain (e.g. "washu" →
https://washu.sonnyscontrols.com).
timeclock ¶
timeclock(
start: str | date | datetime,
end: str | date | datetime,
*,
site_id: int | None = None,
) -> BackOfficeTimeclockResult
Scrape the employee-timesheets report for a date range.
Returns per-shift punch-in/out detail grouped by employee, plus a period grand total parsed from the report's footer row.
The client-level site_code is not applied here. The
BackOffice report is returned for all sites and every shift
carries its own site_code field. Pass an explicit numeric
site_id (the BackOffice URL parameter) to narrow at fetch
time, or filter result.employees[*].shifts locally.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
start
|
str | date | datetime
|
Start date (inclusive) as |
required |
end
|
str | date | datetime
|
End date (inclusive), same types. |
required |
site_id
|
int | None
|
Optional numeric BackOffice site id for URL-level
filtering. When |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
BackOfficeTimeclockResult
|
class: |
BackOfficeTimeclockResult
|
report. |
Raises:
| Type | Description |
|---|---|
BackOfficeCredentialsError
|
|
BackOfficeLoginError
|
BackOffice authentication failed. |
BackOfficeScrapeError
|
The page HTML did not match the expected structure. |