Promotions and previous price
Put products and packs on offer for a few days, read the offer from your web, and announce the reduction with the previous price that the law requires.
A promotion sets a lower price for products or packs between two dates, for the documents of the company, for its website or for both. This page explains how to create it, how a web reads it, how the previous price is calculated and what a store must show to announce a reduction. Promotions are available on every plan that includes products, from Esencial.
What a promotion is
A promotion has a name, a start and an end in the company's time zone (the end
is inclusive), the channels documents and web, and one or more items. Each
item points to a product, a variant, a presentation, a configuration or a pack,
with one mode:
mode | Meaning |
|---|---|
percent_off | A percentage between 0 and 100, both excluded, with 2 decimals |
fixed | A price above 0 with 2 decimals, without VAT or with VAT in the company's regime |
An item cannot appear twice in the same promotion. The status derives from the
dates: scheduled, active or ended, plus cancelled, which is explicit.
- Before it starts, everything can be edited.
- An active promotion accepts only a new end date and being cancelled.
Extending it marks it with
is_extension: true. Changing prices or items answers422 promotion_started. - An ended or cancelled promotion is read-only (
422 promotion_read_only) and can be duplicated.
If two promotions cover the same item, channel and day, the lowest price applies and they are never added together. A customer with a price list does not get the promotion by default, because the price list rules. The promotion can be marked to apply to them too, and then the lowest of the two wins.
API
| Operation | Scope |
|---|---|
GET /v1/companies/{company}/promotions and GET …/promotions/{promotion} | promotions:read |
POST /v1/companies/{company}/promotions and PATCH …/promotions/{promotion} | promotions:write |
POST …/promotions/{promotion}/cancel | promotions:write |
DELETE …/promotions/{promotion} | promotions:delete |
The list filters by status, channel, starts_at[gte|lte],
ends_at[gte|lte] and updated[gte], with cursor pagination. Creation answers
201 with Location. The scopes depend on the products module, so they are
available wherever products are. Errors:
| Status | code | When |
|---|---|---|
| 404 | promotion_not_found | The promotion does not exist |
| 422 | promotion_started | You change prices or items of an active promotion, or delete it |
| 422 | promotion_read_only | You edit, cancel or delete an ended or cancelled promotion |
| 422 | invalid_promotion_item | The item does not exist or belongs to another company, or the mode or value is out of range. param points to the item, for example items.0.target_id |
| 422 | duplicate_promotion_item | The same item twice |
The events are promotion.created, promotion.updated, promotion.started,
promotion.ended, promotion.cancelled and promotion.deleted (the last one
carries only {id, object: "promotion", deleted: true}). When a promotion
starts or ends, a scheduler also emits product.updated for its products and
pack.updated for its packs, so your web refreshes prices without anyone
editing anything. The MCP server mirrors the operations with list_promotions,
get_promotion, create_promotion, update_promotion, cancel_promotion and
delete_promotion.
The promotion object in products and packs
Reading a product (also each variant, presentation and configuration) or a pack,
in either view, always includes promotion: the offer that applies in the web
channel today, or for the date you ask, or null. The shape of the resource
never changes. A promotion only for documents reads as null.
{
"id": "019f1b77-3e90-7c24-8a1d-5f0b2e6c9d48",
"name": "Olivo arbequina",
"price": "39.00",
"sale_price": { "net": "39.00", "tax": "3.90", "gross": "42.90" },
"promotion": {
"id": "019f2c40-8a15-7e63-9b07-4d1c5f2a8e90",
"name": "Semana del jardín",
"label": "Oferta",
"price": { "net": "33.15", "tax": "3.32", "gross": "36.47" },
"regular_price": { "net": "39.00", "tax": "3.90", "gross": "42.90" },
"previous_price": { "net": "39.00", "tax": "3.90", "gross": "42.90" },
"reduction_percent": 14.99,
"announce_reduction": true,
"starts_at": "2026-10-26T00:00:00+01:00",
"ends_at": "2026-11-02T23:59:59+01:00",
"is_extension": false,
"history_complete": true
}
}price and sale_price keep meaning the regular price, and the offer price
goes in promotion.price. The date parameter takes an ISO 8601 date and a
malformed one answers 400 parameter_invalid_iso8601. The updated_at of a
product changes when an offer of any of its items starts or ends.
POST …/products/{product}/resolve-selection also returns the promotion of the
combination it resolves.
Offers on packs
An offer on a pack replaces the target price of the pack, as a negotiated price
does: with fixed, by its price, and with percent_off, by the percentage over
the regular price of the pack. The derived price of a pack and its split always
use the regular prices of the components, while the saving "buying
separately" is measured against their effective prices, offers included. If
the components are on offer and there is no saving left, savings is null and
the web does not show it.
Resolve a price for a date and a channel
POST /v1/companies/{company}/price-lists/resolve and …/resolve-many accept
date (the date of the document, sold_on or today) and channel (documents
or web). Without them they use today and documents. The MCP tools
resolve_catalog_price and resolve_many_catalog_prices take the same two
parameters and return the same result. The offer is a layer applied after the
catalog precedence, on the regular price of that level, and the breakdown of the
preview shows the regular price, the offer applied and its origin. For the olive
tree on 28 October 2026 in the web channel, the answer is 33,15 € with
"Semana del jardín −15 %" over a regular price of 39,00 €.
The previous price
To announce a reduction legally, the store must show as previous price the lowest price applied in the 30 days before the start (art. 20 of Ley 7/1996, as amended by the RDL 24/2021, and art. 6 bis of Directive 98/6/EC). Factuarea keeps the history of the public price for each item and channel, without price lists, and calculates it for you:
- The previous price is the lowest price with VAT in that channel during the 30 days before the start, ignoring the intervals of promotions marked as perishable products close to expiry.
- It stays the same during the whole offer and its extensions.
- If the item goes on sale for the first time, the previous price is
null. - For 30 days after the history was first filled,
history_completeisfalse: the previous price may not include reductions older than that.
If the offer price with VAT is not lower than the previous price, or there is no
previous price, the offer still applies in documents and on the web but cannot
be announced: the API returns announce_reduction: false and
reduction_percent: null. It happens mostly when an offer is repeated within 30
days, because the price of the previous one becomes the previous price. For
example, the olive tree was at 36,47 € until 2 November 2026 and a new offer of
−10 % (38,61 €) starts on 20 November: the previous price is 36,47 € and the
reduction cannot be announced. The app proposes the minimum discount (−16 %) or
the first start date that allows it (3 December 2026).
Legal guide for web stores
This is a technical guide, not legal advice. These are the rules that the API data lets you follow:
- Show the right word. Art. 18.2 of the LOCM reserves terms such as
"Oferta" or "Rebajas", so the company chooses the text for each promotion.
Show
promotion.label, not a text of your own. - Announce only when
announce_reductionistrue. Then showpromotion.previous_priceas the previous price, struck through, next topromotion.price. When it isfalse, show the price without "before" and without strikethrough. The offer still applies at checkout. - Prefer the amount saved. We recommend announcing the reduction with the
amount saved (6,43 € for the olive tree), which is exact. It is
previous_price.grossminusprice.gross. - Round the percentage down.
reduction_percenthas 2 decimals and is calculated over the amounts with VAT already rounded: the olive tree goes from 42,90 € to 36,47 €, which is 14,99 % and not 15 %. If your web shows an integer, round down (14 %) so as not to announce more reduction than the real one. - State the end date.
ends_atis the last instant included. Use its date aspriceValidUntilin schema.org (2026-11-02). - B2B and perishable goods. The previous price is always calculated and your web decides whether to show it. Reductions of perishable products close to expiry do not lower the previous price of future offers.
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Olivo arbequina",
"offers": {
"@type": "Offer",
"price": "36.47",
"priceCurrency": "EUR",
"priceValidUntil": "2026-11-02",
"availability": "https://schema.org/InStock"
}
}Invoice a web sale with an offer
The lines of the v1 and MCP operations that create or edit quotes, pro-forma invoices, delivery notes, invoices and recurring invoices accept two fields:
sold_on: the date of the order, with which the offer in force is resolved. A web that invoices on 3 November an order of 2 November sendssold_on: "2026-11-02"and the line applies "Semana del jardín".channel:documents(default) orweb. A web that invoices its orders sendsweb, so that an offer only for the web is applied on the invoice. Any other value answers422withparamon the line.
A unit_price sent in the line always wins over the offer, because it is the
price an integration charged, and the line has no offer snapshot. There is one
exception: if the unit_price equals the offer price of its channel and date,
the line keeps the snapshot of the offer and counts as sold with it. If it does
not match, it is a charged price without offer.
Without unit_price, the line takes the offer price with price_source: promotion and keeps the offer, its name and the regular unit price. Changing
the date of a draft reprices its offer lines and warns once. In a recurring
invoice, an invoice generated after the end of the offer goes back to the
regular price. Quotes converted after the offer keep the price they had.