Skip to content

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:

bash
composer require magebitcom/magento2-venta-theme-post-purchase

Post-Installation Steps:

bash
bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento cache:flush

Requirements

RequirementVersion
PHP>= 8.1
magebitcom/magento2-core0.1.*
magebitcom/magento2-venta-theme^1.7.0
magento/framework^103.0

Configuration Access: Navigate to Stores > Configuration > Magebit > Post Purchase.

Post Purchase configuration section in the Magento admin

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

General Settings group of the Post Purchase configuration

Configuration options

FieldScopeDefaultDescription
Enable ModuleStore viewNoMaster on/off switch. Offers, products, discounts and targeting are configured per rule.
Fallback Error MessageStore viewThis 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)Global30How 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

Fulfillment group of the Post Purchase configuration

Configuration options

FieldScopeDefaultDescription
Combine With Original OrderStore viewYesWhether a follow-up order placed while the original is still unshipped is priced for combined fulfillment.
Reduced Shipping Method TitleStore viewReduced shipping - follow-up orderShown to the customer as the only shipping method on a combined follow-up order.
Reduced Shipping PriceStore view0Flat 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

Post-Purchase 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

General fieldset of a post-purchase rule

Configuration options

FieldDescription
Rule NameRequired.
DescriptionFree text, for the merchant's own reference.
ActiveYes or No. A new rule defaults to Yes.
PriorityLower number is evaluated first, and the first matching rule wins. Defaults to 0.
WebsitesLeave empty for all websites.
Customer GroupsLeave empty for all groups.
Active FromThe first day this rule takes up orders, counted in the store's own timezone rather than UTC. Leave empty for no start date.
Active ToThe 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

Conditions fieldset of a post-purchase rule

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

Offered Products fieldset of a post-purchase rule

Configuration options

FieldDescription
Offer products matching these conditionsA product filter, limited to attributes flagged for use in promo rules.
Number of products to addHow many matching products to add to the offer, from 1 to 12. Defaults to 4.
Sort products byCatalog position, Newest first, Price: low to high, Price: high to low, or Random. Defaults to Catalog position.
Exclude already purchasedSkip products that were already in the original order. Defaults to Yes.
SelectionAdd 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 chooseShown only when Selection is Let the customer choose. Defaults to 1.
Maximum to chooseShown 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

Timing and Discount fieldset of a post-purchase rule

Configuration options

FieldDescription
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 passedDo 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 shippingRewards 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 shippingUsually 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 isHold 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

Email fieldset of a post-purchase rule

Configuration options

FieldDescription
Email SubjectLeave empty to use the module's built-in subject line, An exclusive offer just for you - grab it before it's gone!
Email TemplateChoose 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 * * * * *.

JobWhat it does
post_purchase_intakeDecides, once, which rule owns a newly placed order, and when that order's offer may be sent and given up on.
post_purchase_dispatchAsks 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 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.

Offer selection dialog on the storefront cart page

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.

The single reduced shipping method offered at checkout on a combined follow-up order

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.

Checkout on a combined follow-up order, with the delivery details greyed out and carried over from the original order

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.

Follow-up order in the admin, showing the linked-order banner and the reduced shipping method

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.

ColumnDescription
Post PurchaseWhether this order was created from a post-purchase offer.
Post Purchase Original OrderThe increment id of the order it was created from.

Sales order grid with the Post Purchase columns switched on

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.

Original order in the admin, naming its linked post-purchase order

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

TableContents
magebit_postpurchase_ruleOne row per merchant-authored rule, including both serialized condition trees. Indexed on (is_active, priority).
magebit_postpurchase_candidateOne 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_purchase and post_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

InterfacePurpose
Api\RuleRepositoryInterfacecreate(), getById(), save(), delete(). Exists so a rule lookup can be decorated.
Api\CandidateRepositoryInterfacecreate(), getByOrderId(), save(). No delete(), because a candidate row stays put.
Api\Data\PostPurchaseAttributesInterfaceNames 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_intake and post_purchase_dispatch appear in cron_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.log rather than var/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.

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.