Post Purchase
The Post Purchase extension emails a customer a one-click offer link after their order is placed, so they can add discounted products to a second, linked order. The discount belongs to the rule that made the offer, so no cart price rule and no coupon code are involved anywhere in the flow.
Overview
Post Purchase is a Venta Theme add-on module. A merchant writes rules under Marketing > Promotions > Post-Purchase Rules, and each rule owns one offer: which orders it covers, which products it offers, how long the offer stays open, and what discount applies.
When a customer accepts an offer, the module builds a checkout that is already filled in from the original order (customer, addresses, shipping method), so the customer only has to pay. Accepting never adds items to the order that triggered the offer. It always creates a separate follow-up order, linked back to the original.
The module targets Hyvä Checkout and requires Venta Theme. The storefront address lock and the leave-guard dialog attach to the Hyvä Checkout layout only.
Documentation
- Benefits - Business benefits and use cases
Key features
Rules
- Rule-based offers under Marketing > Promotions, evaluated in priority order, first match wins
- Order conditions built with Magento's own condition tree (subtotal, grand total, weight, payment method, shipping country, products in the order, and more)
- A separate product filter deciding which products get offered, independent of the conditions that decide when the rule applies
- Campaign start and end dates, website and customer group scoping
- Duplicate action on the grid and on the rule form, creating the copy switched off
Offers
- A signed one-click offer link, verified against a record the customer cannot reach
- Two selection modes: add every offered product, or let the customer choose within a minimum and maximum
- A regular discount window plus an optional later window with its own discount
- Optional free shipping on the offer, separate from the late window's own free-shipping flag
- Composite products offer their variations, so a configurable product offers its variants
Fulfillment
- Optional combined fulfillment, pricing a follow-up order so a warehouse can pack it with the original while that original is still unshipped
- Delivery details fixed on a combined offer, enforced at place-order rather than only in the browser
- A single reduced shipping method on a combined follow-up order, at a price the merchant sets
Admin
- Linked-order banner and an order-information row on both orders in a pair
- Two sales order grid columns showing which orders came from an offer, and which order they came from
Installation
Installation Command:
composer require magebitcom/magento2-venta-theme-post-purchasePost-Installation Steps:
bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento cache:flushRequirements
| Requirement | Version |
|---|---|
| PHP | >= 8.1 |
magebitcom/magento2-core | 0.1.* |
magebitcom/magento2-venta-theme | ^1.7.0 |
magento/framework | ^103.0 |
Configuration Access: Navigate to Stores > Configuration > Magebit > Post Purchase.

WARNING
The module ships with Enable Module set to No. Nothing is emailed and no order is taken up until it is switched on.
Extension Configuration
The section holds two groups. Everything else, including offered products, conditions, timing, discount and email content, is set per rule rather than here.
General Settings

Configuration options
| Field | Scope | Default | Description |
|---|---|---|---|
| Enable Module | Store view | No | Master on/off switch. Offers, products, discounts and targeting are configured per rule. |
| Fallback Error Message | Store view | This post-purchase offer is no longer available for your order. | Shown when a post-purchase offer link is no longer valid for an order. |
| Maximum Offer Age (days) | Global | 30 | How long after an order the module keeps trying to send it an offer. An order still waiting after this is given up on. |
Maximum Offer Age bounds the wait, not the offer. Once the email goes out, the rule's own window runs from the send, so a link issued on the last permitted day still keeps opening for that window afterwards. The field cannot be unset or made unlimited: a value that is not a whole number of days of 1 or more is refused on save, and a missing stored value falls back to 30 days.
This is the one field in the section that is global rather than per store view. It is also the ceiling the rule form validates a rule's send delay against, so the number checked at save time and the number used at runtime cannot disagree.
Fulfillment

Configuration options
| Field | Scope | Default | Description |
|---|---|---|---|
| Combine With Original Order | Store view | Yes | Whether a follow-up order placed while the original is still unshipped is priced for combined fulfillment. |
| Reduced Shipping Method Title | Store view | Reduced shipping - follow-up order | Shown to the customer as the only shipping method on a combined follow-up order. |
| Reduced Shipping Price | Store view | 0 | Flat amount charged for the follow-up parcel. Use 0 to absorb it entirely. |
With Combine With Original Order set to Yes, a follow-up order placed before the original ships keeps the original's delivery details, is offered a single reduced shipping rate, and shows a linked-order banner in the admin. Merging the two parcels stays a warehouse judgement call, so word the shipping method title as a reduced rate rather than a promise that the parcels ship together.
Set it to No and new follow-up orders behave like ordinary standalone orders: ordinary carriers, no address lock, no banner. Offers accepted after the original has shipped always behave like ordinary orders whatever this is set to.
The last two fields are hidden while Combine With Original Order is No. Reduced Shipping Price accepts 0 or more, and an empty value is stored as 0. A rule's own Free shipping flag waives this price for that campaign.
Post-purchase rules
Rules live under Marketing > Promotions > Post-Purchase Rules, in the same group as Catalog Price Rules and Cart Price Rules. The menu entry is hidden while the module is switched off at default scope, and the page itself is guarded by the Magebit_VentaPostPurchase::rule permission.
The rules grid

The grid has five columns: ID, Rule Name, Status, Priority and When Window Passed. A rule's website scope, timing and campaign dates are visible only by opening the rule.
Each row offers two actions. Edit opens the rule. Duplicate creates a copy switched off until it is activated, and asks for confirmation first. The same Duplicate button sits on the toolbar of any saved rule's own form.
General

Configuration options
| Field | Description |
|---|---|
| Rule Name | Required. |
| Description | Free text, for the merchant's own reference. |
| Active | Yes or No. A new rule defaults to Yes. |
| Priority | Lower number is evaluated first, and the first matching rule wins. Defaults to 0. |
| Websites | Leave empty for all websites. |
| Customer Groups | Leave empty for all groups. |
| Active From | The first day this rule takes up orders, counted in the store's own timezone rather than UTC. Leave empty for no start date. |
| Active To | The last day this rule does anything. Leave empty for no end date. |
Both date fields state the pattern the store's admin locale expects, with a sample date in it, and they are parsed strictly. A date the locale cannot read, or one with trailing text after it, is refused with a message naming the expected pattern rather than being reinterpreted.
DANGER
Active To stops everything, including offers that were already emailed. Once it passes, no further order is taken up, anything still waiting to be emailed is dropped, and an offer link already emailed stops opening even if its own window has time left. Leave the campaign long enough for the last offer to run out on its own.
Conditions

A nested ALL/ANY tree deciding which orders the rule covers. Leave it blank to cover every order. It is evaluated against the placed order when the order is taken up, and re-checked against the same rule on every later send attempt.
Three condition types can be added:
- Conditions Combination - a nested ALL/ANY sub-group.
- Product in the order - whether an item is found, or not found, in the order matching a product-attribute filter. It matches the order's top-level purchased lines together with any configurable or grouped parent the purchased product resolves to.
- Order attribute - Subtotal, Subtotal (Incl. Tax), Grand Total, Total Items Quantity, Total Weight, Customer Group, Payment Method, Shipping Method, Shipping Country, Shipping Region and Shipping Postcode.
Offered Products

Configuration options
| Field | Description |
|---|---|
| Offer products matching these conditions | A product filter, limited to attributes flagged for use in promo rules. |
| Number of products to add | How many matching products to add to the offer, from 1 to 12. Defaults to 4. |
| Sort products by | Catalog position, Newest first, Price: low to high, Price: high to low, or Random. Defaults to Catalog position. |
| Exclude already purchased | Skip products that were already in the original order. Defaults to Yes. |
| Selection | Add all offered products, or Let the customer choose. Composite products (configurable, grouped) offer their variations or children. Defaults to Add all offered products. |
| Minimum to choose | Shown only when Selection is Let the customer choose. Defaults to 1. |
| Maximum to choose | Shown only when Selection is Let the customer choose. Defaults to 1. |
TIP
An empty product filter matches every enabled, in-stock product, the same way an empty condition tree matches every order. The rule then offers whatever the first Number of products to add products are in the chosen sort order. Set a filter when the offer should be narrower than the whole catalogue.
A maximum saved below the minimum is raised to the minimum when the offer is judged, so a rule can never make every selection impossible.
Timing & Discount

Configuration options
| Field | Description |
|---|---|
| Send offer after (hours) | Empty sends as soon as the order's status allows. Otherwise the email is held this many hours after the order. Fractional values are allowed (0.25 = 15 min). |
| Offer valid for (hours) | Required. How long the customer has once the offer email is sent. |
| When window passed | Do not offer, or Offer at the late discount. Defaults to Do not offer. |
| Discount (%) | Percent off the offered products while the window is open. 0 to 100. |
| Free shipping | Rewards customers who respond within the window, while the offer can still be packed and shipped with the original order. Defaults to No. |
| Late discount (%) | Applied once the window has passed. Shown only when Offer at the late discount is selected. |
| Late free shipping | Usually left off, because once the window has passed the parcels can no longer be merged. Defaults to No. |
| Late terms valid for (hours) | Required when the rule offers late terms. Counted from the end of the window above. |
| Skip if order status is | Hold the offer back while the order sits in one of these. |
Every window is counted from the send, not from the order. An order that waited for payment still gets its full window. 48 hours plus 24 late hours means the offer ends 72 hours after the email was sent. Shipping the original order moves the switch to late terms earlier, but it does not move that end.
Skip if order status is holds the offer back, it does not drop it. The order's current status is re-checked on every send attempt rather than once, so an order that later leaves a skipped status still gets its offer. A bank transfer that finally clears is the case this covers. Such an order is given up on only once it passes Maximum Offer Age.
Email

Configuration options
| Field | Description |
|---|---|
| Email Subject | Leave empty to use the module's built-in subject line, An exclusive offer just for you - grab it before it's gone! |
| Email Template | Choose a template, or leave on default to use the post-purchase template. |
The template list offers the module's own default plus any transactional email template cloned from it. There is no per-rule sender field: the offer goes out under whichever address the store has set as its general contact (trans_email/ident_general).
A clone of the default template must keep three directives, plus the variable names in its @vars block:
@subject {{var subject}}
{{var post_purchase_url}}
{{for item in items}}The subject is a template variable rather than something set on the message, so a clone that drops that first line loses the rule's own subject line. The link is the only way the email hands the customer a working offer, and the loop is the only place the offered products render. Everything else, including wording, styling and the header and footer references, is free to change.
Saving a rule
A rule that could never send an offer is refused before it is stored, and each message names the numbers involved:
- Offer valid for (hours) is always required.
- Late terms valid for (hours) is required when the rule offers late terms.
- The rule's wait before sending must stay under the store's Maximum Offer Age.
- The send delay plus the offer window plus the late window must fit between Active From and Active To.
Maximum Offer Age is not a field on this form. It is store configuration, one value per installation, under Stores > Configuration > Magebit > Post Purchase.
How an offer reaches a customer
Two cron jobs split the work, both in the default group on * * * * *.
| Job | What it does |
|---|---|
post_purchase_intake | Decides, once, which rule owns a newly placed order, and when that order's offer may be sent and given up on. |
post_purchase_dispatch | Asks every minute whether an order is ready yet, and sends the email when it is. |
Between the two sits one record per order, holding the order, the rule it was assigned to, and the offer's state.
Intake runs once per order, while the order is new. It walks the active rules in priority order and takes the first one whose conditions match. Because it never looks at an order twice, a rule created tomorrow cannot reach an order placed today. An order no rule covers is recorded as such rather than left unexplained.
Dispatch re-checks the same rule, never the others. For each order that has come due it checks, in order, whether the deadline has passed, whether the frozen rule still exists and still covers the order, whether the order has been cancelled or closed, and whether the order is currently in one of the rule's skipped statuses. Only an order that clears all of that is emailed. An order that is not ready yet is left alone and asked again next run.
Two runs cannot both send the same offer: a run claims the record in the database immediately before sending, and a run that loses that claim stops without sending.
Delivery is at-least-once rather than exactly-once. If the email goes out but the record cannot be updated afterwards, the record is reclaimed 15 minutes later and may be emailed a second time. A send that fails spends one of the configured attempts (sales_email/general/async_sending_attempts, default 3), and an offer is given up on once those are spent. The number of offers sent per run is capped by sales_email/general/sending_limit, default 50.
INFO
Only an order placed through a checkout that goes through Magento's order-placement service is taken up. An order created by an ERP sync, a data import, or a direct API call that bypasses that flow is never enrolled.
The offer link
The link carries the order, the rule, an issue time and a signature. Opening it re-checks all of that before anything happens: the module is enabled both at the store the link was opened at and at the order's own store, the signature is valid, the offer was actually sent and is still within its window, and the rule still covers the order.
The issue time is signed into the link, so a customer cannot extend their own offer by editing the URL. Every timing decision is made from the record on the server rather than from the URL.
A link is also refused once a follow-up order already exists for the original order, so revisiting a used link cannot create a second one. Cancelling a follow-up order releases that hold, and the original offer link works again.
Where the customer lands depends on the rule's Selection setting.
Add all offered products
The offer quote is built straight away and the customer goes to checkout with the offered products already in it, at the rule's discount.
Let the customer choose
The customer goes to the cart instead, where a dialog lists the offered products and the bounds the rule set.

The selection is judged on the server against a freshly resolved product list, not against whatever the dialog showed when it loaded. A product that is no longer on offer is rejected, and so is a selection outside the rule's minimum and maximum.
Once a selection is confirmed, the customer's own pre-offer cart is saved and handed back afterwards, and the coupon field is removed from both the cart and the checkout order summary. A coupon code is refused on an offer quote anyway, so the field is taken away rather than left to fail.
Closing the dialog, or leaving checkout, asks the customer to confirm and then releases the offer.
Combined fulfillment
An accepted offer becomes its own order. When the offer was made while the original order was still unshipped, and Combine With Original Order is on, that follow-up order is priced for combined fulfillment.
Such an order keeps the original's delivery details and is offered exactly one shipping method, at the configured Reduced Shipping Price under the configured Reduced Shipping Method Title. Every other carrier is hidden from it. A rule's own Free shipping flag brings that price to zero; a rule's Late free shipping flag never affects it, because a late offer is never priced for combined fulfillment in the first place.

Delivery details on a combined offer are fixed. The checkout greys those fields out, blocks typing and pasting into them, and puts back any value that is changed.

The greying is only the visible half. The server refuses the order outright if the submitted address or email differs from the original order's, and that check is the one that decides, so editing the page or posting through the API is refused at place-order rather than allowed through.

WARNING
The visual address lock attaches to Hyvä Checkout only. A store scope left on Hyvä Checkout's magento_luma compatibility option shows no lock and no leave-guard warning, and the customer finds out only when the order is refused at the end. The refusal itself still holds on every checkout.
Nothing verifies that the two parcels were actually packed together, and the reduced price is charged whether or not they were. The banner is a prompt for the warehouse, not an instruction.
Turn combined fulfillment off with Combine With Original Order, never with anything else. There is a shipping-carrier switch underneath this feature that is deliberately not exposed in the admin. Switching it off out of band leaves a follow-up order with no shipping method at all, because ordinary carriers stay hidden while the replacement is gone, and nothing about it appears in the logs. If a follow-up checkout ever shows no shipping options, check that first.
Post-purchase orders in the admin
Sales order grid
Two columns are added, both hidden until they are switched on under Columns in the grid toolbar. Both sort and filter.
| Column | Description |
|---|---|
| Post Purchase | Whether this order was created from a post-purchase offer. |
| Post Purchase Original Order | The increment id of the order it was created from. |

Order view
A dismissible banner appears at the top of the order view, and on the New Shipment page, whenever there is a link worth announcing. On a follow-up order it names the original order. On an original order it names every follow-up order spun off from it.

Dismissing the banner hides it for that browser session only. It returns on the next visit.
The banner shows only for a pair that was actually priced for combined fulfillment, and it follows the order's own history rather than today's settings: an order stamped while the setting was on keeps its banner after the setting is switched off. Only new orders stop being stamped.
Both order views also carry a row in the order information table pointing at the other order, labelled Original Order on a follow-up order and Post Purchase Order on an original. That row is not tied to combined fulfillment, so it shows for every linked pair and stays after the banner is dismissed.
Returns
Each order in a pair is refunded or returned through the ordinary credit memo process for that order, on its own. The module does not merge the two or special-case a return spanning both.
Developer notes
Tables
| Table | Contents |
|---|---|
magebit_postpurchase_rule | One row per merchant-authored rule, including both serialized condition trees. Indexed on (is_active, priority). |
magebit_postpurchase_candidate | One row per order, ever. Holds the frozen rule, the offer's state, its two deadlines and its issue time. order_id is unique and cascades from sales_order. Indexed on (state, send_after). |
A candidate row is never deleted, only closed with a reason: no_rule, campaign_too_short, gave_up, terminal_status, rule_withdrawn or send_failed.
Columns added to core tables
sales_order-is_post_purchase,post_purchase_original_order_id,post_purchase_combine_shipment,post_purchase_claim,post_purchase_intake_state.sales_order_grid-is_post_purchaseandpost_purchase_original_increment_id, the latter joined in at grid-index time.quote-is_post_purchase,post_purchase_original_order_id,post_purchase_discount_percent,post_purchase_free_shipping,post_purchase_combine_shipment,post_purchase_issued_at.quote_item-post_purchase_offer_item, marking a row the offer added so the discount applies to it.
Api\Data\PostPurchaseAttributesInterface is the single source of these names. An integrator writing an order through a path that bypasses checkout can set post_purchase_intake_state to pending to opt that order in.
Service contracts
| Interface | Purpose |
|---|---|
Api\RuleRepositoryInterface | create(), getById(), save(), delete(). Exists so a rule lookup can be decorated. |
Api\CandidateRepositoryInterface | create(), getByOrderId(), save(). No delete(), because a candidate row stays put. |
Api\Data\PostPurchaseAttributesInterface | Names every order and quote column the module stamps. |
etc/extension_attributes.xml adds is_post_purchase and post_purchase_original_order_id to OrderInterface, so an OMS, WMS or ERP reading orders through the standard API can tell which orders came from an offer and which order each came from.
The discount
The discount is resolved once when the offer quote is built and stored on that quote, then applied by the module's own totals collector at sort order 320, after Magento's sales rules and before shipping and tax. Only quote rows the offer added are discounted, so a product the customer adds alongside the offer is priced normally.
At place-order the stored discount is re-resolved and the order is refused if it no longer matches. That is what stops a quote left open in a browser tab from being submitted at a price the rule has stopped offering.
Troubleshooting
No offers are being sent
- Confirm Enable Module is Yes for the order's store view.
- Confirm Magento cron is running, and that
post_purchase_intakeandpost_purchase_dispatchappear incron_schedule. - Check the rule is Active, that its campaign dates cover today in the store's timezone, and that its website and customer group scope include the order.
- Check the order is not sitting in one of the rule's Skip if order status is statuses.
- Check the order has not passed Maximum Offer Age.
- Failures from both jobs are logged with exception context, so they land in
var/log/exception.lograther thanvar/log/system.log.
A rule is matching orders but never emails anything
An offer with no products left to sell is not emailed at all. The attempt is logged and no send attempt is spent, so a rule whose products have gone out of stock or disabled goes quiet rather than sending an empty offer. Check that the rule's product filter still matches enabled, in-stock products, and that Exclude already purchased is not removing everything the filter found.
An offer link shows an error
The link is refused when the module is off for the order's store, when the offer's window (and late window) have passed, when the campaign's Active To has passed, when the rule no longer covers the order, or when a follow-up order already exists for the original. Cancelling that follow-up order makes the link work again.
A follow-up checkout shows no shipping methods
The combined fulfillment carrier has been switched off out of band while combined fulfillment is still on. Ordinary carriers stay hidden from a combined order, so removing its one replacement leaves nothing. Nothing is logged, because the carrier that would log it is never asked.
The customer could edit a locked address field
The visual lock attaches to Hyvä Checkout only. Check which checkout the store scope uses under Stores > Configuration > Hyvä Themes > Checkout. The order is still refused at place-order if the address differs from the original.