How to Build a Custom "Scheduled Order" Module for Recurring B2B Purchases in Magento 2

Introduction
If your Magento 2 store handles B2B customers who buy regularly — think office supplies, chemicals, or food distribution — building a custom "Scheduled Order" module can save time, lower churn, and improve retention. In this post I’ll walk you through a practical approach to implement a recurring scheduled-order system in Magento 2: architecture, cron jobs, payment automation, admin UI, and customer experience. I’ll keep it relaxed and practical, with step-by-step code examples you can adapt.
What this module does (brief)
- Create and store subscription-like scheduled orders (not a subscription for content, but scheduled B2B purchases).
- Run cron jobs to generate actual orders at the right time.
- Charge customers automatically using saved payment tokens / vault.
- Provide admin UI to manage schedules and a merchant-friendly order log.
- Provide customer-facing controls to change frequency, pause or cancel schedules.
High-level architecture
Think of the module as four layers:
- Data model: entities to store scheduled order templates and related metadata.
- Scheduler: cron jobs that inspect the schedule table and generate real sales orders.
- Payment integration: using Magento payment tokens (vault) or secure payment provider APIs for automatic capture.
- Interfaces: Admin panel for merchants, and customer account UI for buyers to manage frequency and items.
Entities
Key database entities:
- scheduled_order_template — the blueprint for creating orders (customer, items, frequency, next_run_at, status)
- scheduled_order_history — record of generated orders (template_id, order_id, executed_at, result, payment_reference)
Keep the model small and query-friendly so the cron can run efficiently on large volumes.
Module skeleton and registration
Create a standard module namespace e.g. MageFine/ScheduledOrder. Minimal files:
app/code/MageFine/ScheduledOrder/registration.php
<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
ComponentRegistrar::MODULE,
'MageFine_ScheduledOrder',
__DIR__
);
app/code/MageFine/ScheduledOrder/etc/module.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="MageFine_ScheduledOrder" setup_version="1.0.0" />
</config>
Run bin/magento setup:upgrade after adding files.
Database schema (install schema / declarative)
Use declarative schema (db_schema.xml). Minimal schema sketch:
app/code/MageFine/ScheduledOrder/etc/db_schema.xml
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
<table name="magefine_scheduled_order_template" resource="default" engine="innodb" comment="Scheduled order templates">
<column xsi:type="int" name="template_id" nullable="false" identity="true" unsigned="true" comment="Template ID"/>
<column xsi:type="int" name="customer_id" nullable="false" unsigned="true" comment="Customer ID"/>
<column xsi:type="text" name="items_json" nullable="false" comment="Items JSON"/>
<column xsi:type="varchar" name="frequency" nullable="false" length="50" comment="Frequency (e.g. weekly, monthly)"/>
<column xsi:type="timestamp" name="next_run_at" nullable="true" on_update="false" comment="Next run"/>
<column xsi:type="varchar" name="status" nullable="false" length="50" default="active" comment="active|paused|cancelled"/>
<column xsi:type="varchar" name="payment_token" nullable="true" length="255" comment="Vault token or gateway reference"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="template_id"/>
</constraint>
</table>
<table name="magefine_scheduled_order_history" resource="default" engine="innodb" comment="Scheduled order history">
<column xsi:type="int" name="history_id" nullable="false" identity="true" unsigned="true"/>
<column xsi:type="int" name="template_id" nullable="false" unsigned="true"/>
<column xsi:type="int" name="order_id" nullable="true" unsigned="true"/>
<column xsi:type="timestamp" name="executed_at" nullable="false" on_update="false"/>
<column xsi:type="varchar" name="status" length="50" nullable="false"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="history_id"/>
</constraint>
</table>
</schema>
Design note: store items as JSON or a separate child table. JSON is quick to prototype; a child table is better for complex queries.
Cron architecture — scheduling and processing
Replace naive loops with batched processing and carefully handle failures. Cron responsibilities:
- Find templates where next_run_at <= now and status = active.
- Lock each template to avoid double-processing (use DB flag or optimistic locking).
- Create a cart/order programmatically for the customer.
- Charge using vault token or create payment intent with stored instrument.
- Record history and compute next_run_at based on frequency.
Define cron job
app/code/MageFine/ScheduledOrder/etc/crontab.xml
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
<group id="default">
<job name="magefine_scheduledorder_run" instance="MageFine\ScheduledOrder\Cron\ProcessScheduledOrders" method="execute">
<schedule>*/5 * * * *</schedule>
</job>
</group>
</config>
Cron implementation—key points
Keep the cron class small and delegate work to service classes:
app/code/MageFine/ScheduledOrder/Cron/ProcessScheduledOrders.php
<?php
namespace MageFine\ScheduledOrder\Cron;
class ProcessScheduledOrders
{
private $templateRepository;
private $processor;
public function __construct(
\MageFine\ScheduledOrder\Api\TemplateRepositoryInterface $templateRepository,
\MageFine\ScheduledOrder\Model\Processor $processor
) {
$this->templateRepository = $templateRepository;
$this->processor = $processor;
}
public function execute()
{
$templates = $this->templateRepository->getDueTemplates(100); // batch size
foreach ($templates as $template) {
try {
$this->processor->processTemplate($template);
} catch (\Exception $e) {
// log, mark history as failed, notify admin if repeated errors
}
}
}
}
Processor flow
Processor responsibilities:
- Acquire lock for the template (for concurrency safety).
- Build quote for customer (set customer, items, addresses, shipping method).
- Process payment using saved token via Payment Gateway / Magento Vault.
- Create order, invoices, shipments if needed.
- Update next_run_at and create history record.
// Pseudocode for Model/Processor
public function processTemplate($template) {
if (!$this->lock($template)) return; // another process got it
$quote = $this->quoteService->createQuoteForCustomer($template->getCustomerId());
$this->quoteService->addItemsFromTemplate($quote, $template->getItemsJson());
$this->quoteService->setShippingAndBilling($quote, $template);
// Use payment token if available
$paymentResult = $this->paymentService->chargeUsingToken($quote, $template->getPaymentToken());
if ($paymentResult->isSuccess()) {
$order = $this->orderService->submitQuote($quote);
$this->historyRepo->createSuccess($template->getId(), $order->getId(), $paymentResult->getReference());
$this->templateRepo->updateNextRunAt($template, $this->calculateNextRun($template));
} else {
$this->historyRepo->createFailure($template->getId(), $paymentResult->getMessage());
// Optionally try to notify customer and admin
}
$this->unlock($template);
}
Note: keep payment capture and order creation atomic where possible. If capture happens via gateway webhooks you may create a pending order and finalize it on successful capture callback.
Payment integration and secure automatic payments
Security and PCI compliance are essential. The recommended approach is:
- Use the Magento Vault (Payment tokenization) so you don’t store card data in your DB.
- If using a gateway that supports tokenization and recurring billing (Stripe, Adyen, Authorize.net, Braintree), use the gateway’s token and charge APIs via the payment token saved in vault.
How to use vault tokens
On checkout, enable saving payment method to vault for eligible customers. Save the vault token or payment instrument id in scheduled_order_template.payment_token. At cron time, call the payment gateway charge API using the token.
// Simplified PaymentService::chargeUsingToken
public function chargeUsingToken(\Magento\Quote\Model\Quote $quote, $vaultToken) {
// Build payment request according to gateway SDK
// For example: $gatewayClient->charge(['token' => $vaultToken, 'amount' => $quote->getGrandTotal(), 'currency' => $quote->getCurrencyCode()]);
// Handle responses and translate into local result object
}
Two strategies for order creation & capture:
- Capture first, then create order: call gateway charge; on success, create a paid order in Magento and mark as invoice created.
- Create order then capture: place order as pending_payment, attempt capture; update order status based on gateway response and webhook callbacks.
Which to choose depends on your gateway and webhook reliability. For B2B, capturing first is often easier to reconcile because you only create an order when payment succeeded.
Failure handling and retry strategy
If a payment fails:
- Record the failure and increment an attempt counter.
- Try a configurable retry policy: e.g. retry after 1 day, 3 days, then pause and notify admin/customer.
- Consider dunning flow: automated emails to update payment method or notify about failed charge.
Admin Interface: managing scheduled orders
Admin should have a clear UI to manage templates and review execution history. Key screens:
- Grid listing scheduled templates (filters: customer, status, next_run_at).
- Edit form: change frequency, next_run_at, payment token selection, pause/cancel actions.
- History tab to view generated orders and statuses.
Admin menu and ACL
app/code/MageFine/ScheduledOrder/etc/adminhtml/menu.xml
<menu xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Backend:etc/menu.xsd">
<add id="MageFine_ScheduledOrder::main" title="Scheduled Orders" module="MageFine_ScheduledOrder" sortOrder="300" parent="Magento_Backend::sales" action="magefine_scheduledorder/template/index" resource="MageFine_ScheduledOrder::manage" />
</menu>
app/code/MageFine/ScheduledOrder/etc/acl.xml
<acl>
<resources>
<resource id="Magento_Backend::admin">
<resource id="MageFine_ScheduledOrder::manage" title="Manage Scheduled Orders" />
</resource>
</resources>
</acl>
Admin grid and edit form
Use UI components for the grid and form. Columns: template_id, customer_name, frequency, next_run_at, payment_method, status, actions. The edit form should allow merchants to directly trigger a manual run or cancel future runs.
// Minimal UI component xml snippets omitted for brevity — implement a standard uiComponent grid and form.
Customer experience: frontend management
Allow B2B customers to:
- Create a schedule when placing an order (checkbox “Schedule this order”), pick frequency, and next run date.
- Edit schedule in the account dashboard: change frequency, items, pause/cancel or change payment method.
- View upcoming and past scheduled shipments and invoices.
Creating schedules at checkout
Add a simple form field at checkout or in a quick-reorder flow. Example quick UI:
<div class="scheduled-order-option">
<input type="checkbox" name="scheduled" id="scheduled" />
<label for="scheduled">Create a scheduled order</label>
<select name="frequency">
<option value="weekly">Weekly</option>
<option value="biweekly">Bi-weekly</option>
<option value="monthly">Monthly</option>
</select>
<input type="date" name="next_run_at" />
</div>
On submit, create a scheduled template for the customer instead of (or in addition to) creating a normal order depending on business rules. For B2B repeat buys you often want both: create an immediate order and create a schedule for future orders.
Editing schedules in My Account
Create a simple grid in My Account that lists templates and offers actions: Edit, Pause, Resume, Cancel, Run Now. When editing items, you can either let the change apply to next runs only or optionally update existing future shipments.
Frequency rules and next run calculation
Keep frequency logic extensible. Implement frequency strategies:
- Fixed interval (e.g., every 7 days)
- One-month increments (handle month lengths carefully)
- Custom cron-like expressions for advanced clients
// Example simple next-run calculator
switch ($frequency) {
case 'weekly':
$next = (new \DateTime($current))->modify('+7 days');
break;
case 'biweekly':
$next = (new \DateTime($current))->modify('+14 days');
break;
case 'monthly':
$next = (new \DateTime($current))->modify('+1 month');
break;
}
Edge cases: end-of-month behavior, timezones (store timestamps as UTC) and daylight saving. Always store runs in UTC and convert for display.
Concurrency and locking strategies
If crons run on multiple servers you must avoid double-processing. Options:
- DB-level lock: update a processing flag with WHERE status='active' AND next_run_at <= now AND processing=0; then set processing=1 in one atomic update and check affected rows.
- Use SELECT ... FOR UPDATE in a transaction.
- Leverage Redis or another distributed lock mechanism if you already have one in your stack.
// Example atomic update technique
UPDATE magefine_scheduled_order_template
SET processing = 1
WHERE template_id = :id AND processing = 0 AND status = 'active' AND next_run_at <= :now
Logging, notifications and observability
Visibility is key for B2B operations. Implement:
- History records for each execution.
- Admin email alerts for repeated failures per customer/template.
- Dashboards or counters (failed attempts, upcoming runs) in admin.
- Structured logs (JSON) for cron runs so you can ingest them into log aggregation tools.
Testing and deployment tips
Test carefully — scheduled systems can create a lot of financial impact if broken.
- Unit tests for frequency logic and template creation.
- Integration tests for cron behavior (simulate time and token responses).
- Use a sandbox gateway for payment tests and never test real card data in dev environments.
- Deploy with feature flags so you can enable the scheduler per environment and gradually turn it on.
Practical code snippets: create quote & place order programmatically
Here’s a compact example showing the main steps to create a quote for a customer and convert it to an order. This assumes DI and proper service objects — consider this pseudocode to adapt.
// 1) Create quote for customer
$quote = $this->quoteFactory->create();
$quote->setStore($store);
$quote->assignCustomer($customer); // sets group, addresses
// 2) Add items
foreach ($items as $itemData) {
$product = $this->productRepository->getById($itemData['product_id']);
$quote->addProduct($product, intval($itemData['qty']));
}
// 3) Set addresses, shipping method
$quote->getBillingAddress()->addData($billingData);
$quote->getShippingAddress()->addData($shippingData);
$quote->getShippingAddress()->setCollectShippingRates(true)->collectShippingRates();
$quote->getShippingAddress()->setShippingMethod('freeshipping_freeshipping');
// 4) Set payment
$quote->getPayment()->setMethod('vault'); // your custom method that uses token
$quote->setTotalsCollectedFlag(false)->collectTotals();
// 5) Submit quote
$order = $this->cartManagement->submit($quote);
Real implementation must adapt to tax rules, custom price adjustments, tier pricing, and negotiated B2B pricing.
Security and compliance notes
Handle payment tokens and customer data carefully:
- Never store raw card numbers.
- Use Magento Vault to keep PCI surface minimal.
- Review GDPR and local data retention laws for storing customer order history.
- Audit admin actions on schedules (who changed frequency or payment token).
Performance considerations
When dozens of thousands of scheduled templates exist, performance matters:
- Process in batches and paginate queries.
- Precompute criteria where possible (e.g., indexes on next_run_at and status).
- Use asynchronous jobs for non-critical post-processing (e.g., generating PDF invoices).
Example end-to-end flow (concrete)
Let’s walk through one real flow for clarity:
- Customer places a one-off order and checks "Create scheduled order" with frequency monthly.
- At checkout the system creates a normal order and also creates a scheduled_order_template with next_run_at = 30 days from now and stores the vault token.
- Cron runs every 5 minutes and picks up templates with next_run_at <= now.
- Processor builds a quote based on template and tries to charge via token. It attempts capture via the gateway SDK.
- If capture succeeds, the processor creates a paid order, records history, and sets next_run_at += 1 month. If it fails, history records the error and the template's attempt_count increments.
- After 3 failed attempts, the template is paused and an email to the customer and the site admin is sent to update payment method.
Extensibility ideas
Once you have a basic system, you can add:
- Advanced frequency rules (N-th business day of month).
- Support for bundled shipments and partial shipments.
- Integration with ERP for automatic PO creation.
- Bulk import of templates from CSV for onboarding large B2B clients.
Common pitfalls and how to avoid them
- Double charging due to concurrent cron — use locks.
- Payment tokens expired — implement graceful retries and notifications.
- Time zone issues — store UTC, display local time.
- Missing taxes / incorrect pricing on regenerated orders — always collect totals and load current catalog prices if desired.
Summary and next steps
Building a custom Scheduled Order module in Magento 2 is a rewarding project for B2B merchants. The core features are straightforward: data model, cron-driven processor, payment token usage, admin and customer UIs. The hard parts are payment reliability, concurrency, and edge-case frequency rules.
If you’re implementing this in production, start small: implement templates, a basic cron, and integrate with a sandbox gateway. Add admin and customer UIs next and iterate on failure handling and observability.
If you want, I can help with a starter repository layout, scaffold the UI components, or draft the processor class and payment integration for your specific gateway (Stripe, Adyen, Braintree, etc.).
Thanks for reading — hope this helps you get the module running quickly for your B2B flows. Ping me if you want code tailored to your store’s setup.
Keywords: Magento 2 Scheduled Order, recurring purchases, B2B scheduled orders, MageFine, Magento Vault, cron jobs