API¶
Profitability Copilot answers a JSON API over HTTPS, for the system that keeps the merchant's own records rather than for a browser.
This page is generated from the extension's contract declaration — the same data the routes are served from, the committed snapshot is diffed against and the release's smoke pass walks. So it cannot describe a route the version you installed does not answer, and a field that moves moves this page or fails the build.
A caller authenticates as one of OpenCart's own API users, under
System > Users > API, and the API has to be switched on for the
extension. That switch is under Extensions > Extensions > Modules, on
this extension's own settings screen, in its API section. With it off,
every route here answers 404 api_disabled, so an extension with its API off
is shaped like one that never had it.
That switch is independent of the extension's own status.
Profitability Copilot can be switched off while its API keeps answering, and that is
deliberate: a merchant who switches the extension off has stopped it doing
its job in the storefront, which is precisely when the system holding their
records still needs to read what is already there. Coupling the two would
break a nightly sync every time the merchant closed a window, with
404 api_disabled as the only clue.
Every answer is the same envelope¶
A success carries data. A collection carries page beside it:
{"data": [], "page": {"limit": 50, "count": 0, "cursor": null}}
A refusal carries error, and no data at all:
{"error": {"code": "not_found", "message": "No such record.", "status": 404}}
Exactly one of the two is present, always — including for a refusal the store made before it read anything of the call.
limit defaults to 50 and is clamped to 200; page.limit is the value
actually applied, so a caller who asked for more can see they were clamped
rather than infer it from a short page. page.cursor is null on the last page,
and it is the only reliable end-of-walk signal: a full page is not evidence of
more rows and a short one is not evidence of none. There is no total.
A money value is an object carrying amount, a string at four decimal places,
and the currency it is in. A day is YYYY-MM-DD; a moment is RFC 3339 with an
offset, and which offset is the last section on this page.
Branch on code. The message is prose for a person reading a log and is not
promised; status in the body is the status on the wire. A refusal that names
the parameters it rejected carries fields, a map of parameter name to the
codes that rejected it.
These codes belong to the envelope and mean the same thing in every one of our extensions that serves an API:
| Code | Status |
|---|---|
api_disabled |
404 |
tls_required |
426 |
unauthorized |
401 |
unknown_version |
404 |
unknown_route |
404 |
method_not_allowed |
405 |
not_found |
404 |
invalid_parameter |
400 |
malformed_json |
400 |
unsupported_media_type |
415 |
payload_too_large |
413 |
internal_error |
500 |
v1¶
Two read-only daily collections, for the reporting tool the business already
runs: walk profit for the day-by-day profit and loss, walk product_profit
for the same days broken down by product, and aggregate either into whatever
period you report on.
Codes of its own¶
Beside the envelope's, this version answers with codes for conditions in the merchant's own domain:
| Code | Status |
|---|---|
status_set_empty |
409 |
date_range_invalid |
400 |
profit¶
One day of one store, as the profit and loss the merchant's own dashboard prints: the same arithmetic over that day's lines, with the day as the period its expenses amortise into.
index.php?route=extension/profitability_copilot/api/v1/profit
| The collection | this route, ordered date DESC, store_id DESC |
| Verbs | GET |
Fields¶
In the order they are emitted in.
| Field | Type | Notes |
|---|---|---|
date |
string, YYYY-MM-DD |
The order's own date, frozen when the order became real — the same day core's own revenue reports key on, so the two agree. |
store_id |
integer | 0 is the default store. |
orders |
integer | Distinct counted orders with at least one line on this day. |
units |
integer | |
revenue |
money object | Ex tax, and gross of anything that came back. What was returned is its own row below rather than netted out here. |
cogs |
money object | What the goods cost, at the cost the product carried on the day. Excludes every line with no cost — see lines_excluded. |
discounts |
money object | A positive magnitude, allocated across the day's lines. |
payment_fees |
money object | |
fulfilment |
money object | |
returns |
money object | null | Null where the merchant has nominated no return status, which is nobody has said rather than nothing came back. |
charges |
money object | What customers paid under shipping, handling and low_order_fee, once per order: order-level revenue, never allocated to a product. Signed. |
unclassified |
money object | Order-total rows this extension has no treatment for, summed. Signed. |
contribution_margin |
money object | null | An amount, not a rate. There is no margin and no markup anywhere in this API, deliberately: a rate on a row a client will sum is a rate they will average, and the average of a month of daily margins is not that month's margin. The numerator and the denominator are both here, so a client divides once, at the grain they are actually reporting at. Null where returns is. |
expenses |
money object | null | The day's share of the recurring operating expenses, amortised. Null where the merchant has recorded none. |
net_profit |
money object | null | Null where expenses or contribution_margin is. |
lines_counted |
integer | Lines with a cost, which are the lines every money figure above is made of. |
lines_excluded |
integer | Lines with no cost at all. Not zero-cost lines: they are out of revenue as well as out of cogs. |
revenue_excluded |
money object | What those excluded lines actually sold for, so the size of the gap is a figure rather than a feeling. |
cost_basis |
object | The cost_basis shape. How the counted lines were costed. estimated and backfilled are not measurements — see the disclosures. |
orders_unsettled |
integer | Orders whose own arithmetic did not reconcile against what the customer was charged. They are included in every figure above anyway. |
as_of |
string, RFC 3339 | When this row was computed. There is no change feed and no modified-since filter: re-fetch the range and compare. |
The collection¶
The order is the contract, not a default. It is date DESC, store_id DESC,
it is what the cursor is keyed on, and there is no sort parameter: a second
order is a second permanent promise and a second index to keep.
Walk it with &cursor=, taking the value from the previous page's
page.cursor and changing nothing else. The cursor names a row rather than a
position, so a row inserted mid-walk cannot shift the page under you.
It may be narrowed by these, and by nothing else — any other parameter is
400 invalid_parameter naming itself in fields.
| Parameter | Type | Notes |
|---|---|---|
filter_date_from |
string, YYYY-MM-DD |
Inclusive, on the order's own date. Unfiltered is every day the store has ever recorded. |
filter_date_to |
string, YYYY-MM-DD |
Inclusive. |
filter_store_id |
integer | 0 is the default store. Unfiltered is every store, which is what the credential authorises. |
product_profit¶
One product on one day of one store: what it sold, what it cost, what came back and what was left.
index.php?route=extension/profitability_copilot/api/v1/product_profit
| The collection | this route, ordered date DESC, store_id DESC, product_id DESC |
| Verbs | GET |
Fields¶
In the order they are emitted in.
| Field | Type | Notes |
|---|---|---|
date |
string, YYYY-MM-DD |
|
store_id |
integer | 0 is the default store. |
product_id |
integer | OpenCart's own product_id, unprefixed because every id in this API is OpenCart's and there is nothing of ours to confuse it with. A product deleted from the catalogue keeps appearing here: the figures were frozen on the order. |
units |
integer | |
revenue |
money object | Ex tax, and gross of what came back. |
cogs |
money object | null | Null where no line of this product on this day carried a cost. Null is not costed; 0.0000 is genuinely free. |
discounts |
money object | A positive magnitude. |
payment_fees |
money object | |
fulfilment |
money object | |
returned_revenue |
money object | What the customer paid for the units that came back: net of their share of the line's discount, which stays in discounts, so a discount is never taken off twice. Payment fees and fulfilment stay spent. Allocated: OpenCart does not record which line a return came from — see the disclosures. |
profit |
money object | null | Revenue less cost, fees, fulfilment and discounts, less what came back net of the cost of the goods that came back with it. There is no margin and no markup, deliberately: a rate on a row a client will sum is a rate they will average, and the average of daily margins is not the period margin. Divide profit by revenue yourself, once, at the grain you are reporting at. Null where cogs is. |
cost_basis |
string | One of actual, estimated, backfilled, unknown, mixed. mixed where this product's lines on this day were not all costed the same way. unknown is a product nobody has costed, and is the one value that means the money above is null. |
lines_excluded |
integer | This product's lines on this day with no cost. |
revenue_excluded |
money object | What those lines sold for. |
orders_unsettled |
integer | The day and store's own count, repeated on each of its products. It is a fact about the day rather than about the product. |
as_of |
string, RFC 3339 |
The collection¶
The order is the contract, not a default. It is date DESC, store_id DESC,
product_id DESC, it is what the cursor is keyed on, and there is no sort
parameter: a second order is a second permanent promise and a second index to
keep.
Walk it with &cursor=, taking the value from the previous page's
page.cursor and changing nothing else. The cursor names a row rather than a
position, so a row inserted mid-walk cannot shift the page under you.
It may be narrowed by these, and by nothing else — any other parameter is
400 invalid_parameter naming itself in fields.
| Parameter | Type | Notes |
|---|---|---|
filter_date_from |
string, YYYY-MM-DD |
Inclusive, on the order's own date. |
filter_date_to |
string, YYYY-MM-DD |
Inclusive. |
filter_store_id |
integer | 0 is the default store. |
filter_product_id |
integer | |
filter_cost_basis |
string | One of actual, estimated, backfilled, unknown. Narrows the lines a row is made of, not the rows. A page filtered to actual carries the money of the actual-costed lines only. mixed is not a stored value and is not accepted here. |
Embedded shapes¶
What a field of type array or object holds. Each is declared once and
referred to from wherever it is embedded, so two copies cannot come to
disagree.
cost_basis¶
How the day's counted lines were costed, as a count apiece.
| Field | Type | Notes |
|---|---|---|
actual |
integer | Lines costed from what the merchant recorded the product costs. |
estimated |
integer | Lines costed from the merchant's default margin, because the product had no cost of its own. |
backfilled |
integer | Lines recomputed after the fact, at a cost recorded later than the order. |
What a client has to do¶
Three obligations, and a client that meets them keeps working across every non-breaking change this API is allowed to make:
- Ignore a field you do not recognise. A new field is added without notice and without a version segment, so a client that refuses an unexpected key breaks on a change that was promised to be safe.
- Tolerate an unrecognised member of an enum exactly as you tolerate an
unrecognised field. A new state, resolution, verdict or actor is the same
kind of additive change as a new field. Branch on the members you know and
route the rest to a default; never
switchwith an exhaustiveelsethat throws. - Treat a response that does not parse as this envelope as a transport failure, and retry it. A proxy's HTML error page, a truncated body, a maintenance splash: none of those came from this API, and reading them as data is how a client invents a state the store never reported.
Timestamps, and which store answered¶
Every timestamp is RFC 3339 with an offset, and the offset is the one of whichever store the hostname you polled resolved to — not UTC, and not the default store's. A multi-store installation polled on two hostnames answers the same moment with two different offsets. Parse to an instant and compare instants; never compare the strings, and never assume the offset is stable across stores or across a daylight-saving boundary.
What v1 owes you before you build on it¶
Facts about this surface that no field list states, and that a client author would otherwise find out from a support ticket:
- Turning this on shows every purchase price to every holder of an OpenCart
API credential, and that credential cannot be scoped. One
oc_apiuser opens every extension's API and core's ownapi/orderbesides, across every store in the installation — there is no per-extension, per-resource or per-store grant to give instead. The cost permission on the product form governs the admin screen and does not apply here and cannot: it is a user-group permission, and an API credential is not a user. So the question is not who may read margin in the admin. It is whether everybody already holding a key to this store should be able to read what the merchant pays their suppliers. - These figures move, and there is no change feed. A day fetched last month
can read differently today: the money was frozen when the order became real,
but whether the order counts is read live off its current status, and so is
every return filed against it. There is no
filter_date_modified_from— it would need a per-row timestamp the snapshot has no other reason to write, and it would still miss every change that is live rather than stored, which is most of them. Re-fetch the range and compareas_of. - Excluded lines are not zero-cost lines.
lines_excludedabove zero means a partial picture: those lines are out ofcogsand out ofrevenue, because counting revenue with no cost against it would make the day look more profitable than it was.revenue_excludedis what they actually sold for, so the size of the gap is measurable rather than a feeling. estimatedandbackfilledare not measurements. Anestimatedline was costed from the merchant's default margin because the product had no cost recorded; abackfilledone was recomputed after the fact, at a cost recorded later than the order. Both are the merchant asserting a number rather than the extension observing one. Onlyactualis a cost that existed when the order did.- Returns are an allocation. OpenCart records that a quantity of a product came back against an order and does not record which line it came back from, so what came back is allocated greedily across that product's lines in the order they were sold. On an order with one line of a product it is exact; on an order with two lines of the same product at different prices it is an allocation, and no record anywhere in the store could make it otherwise.
- A day with no sales does not appear. Both collections are sparse: a row
exists only where at least one counted line falls on that day and that store.
So a day the merchant spent money and sold nothing is absent, and
expensessummed over the days a range returns can be less than the same range's figure on their own dashboard — which amortises the expenses across every day in the period, sold or not. Sum this collection to reconcile against this collection, and use the dashboard's own range for the merchant's period figure.