Commerce

Medusa v2 Workflow Compensation Cannot Cross the Process Boundary

Medusa v2's Workflows SDK rolls back database writes when a step fails. It cannot un-emit an event, un-send an email, un-write to your ERP, or un-charge…

MManojAugust 5, 202614 min read
eCommerce#medusa#commerce#workflows#distributed-systems
Share

This guide on medusa v2 workflow compensation is written for Indian SMEs, with code samples, ERPNext / Medusa recipes, and step-by-step fixes you can copy into a real project. Medusa v2's Workflows SDK gives every step an optional compensation function, and the marketing calls it automatic rollback. It is not. It is the saga pattern: semantic compensation, not atomic rollback. Compensation can delete the order rows a step wrote. It cannot un-emit an event a subscriber already consumed, un-send the confirmation email that subscriber triggered, un-write the sales order it pushed into your ERP, or un-charge the card. Three separate bugs in the current release — #15122, #15306 and #16292 — are the same defect wearing three hats, and all three are still live in v2.18.0. This post shows exactly where the boundary sits, what it costs when you cross it, and the three patterns that keep you on the safe side of it. This guide on medusa v2 workflow compensation is written for Indian SMEs, with code samples, ERPNext / Medusa recipes, and step-by-step fixes you can copy into a real project.

Already tuning your Medusa checkout?

Correctness and latency fail in the same place. The Medusa v2 checkout performance guide covers the quadratic variant pricing lookup that makes completeCartWorkflow slow, and the Razorpay India store guide covers wiring a payment provider that behaves under retry.

The first time I saw this in production, the database was spotless and the customer was furious. Order rolled back, admin showed nothing, and the customer was holding a confirmation email with an order number that had never existed. I am Manoj, commerce and ERP implementation lead at Mith Tech in Bengaluru, and stitching Medusa to ERP and accounting systems is where this failure mode stops being theoretical.

What compensation actually promises

The Workflows SDK implements the saga pattern. A saga gives you semantic compensation — a reversing action you wrote — not atomic rollback. Steps are not wrapped in one database transaction; each step commits on its own. When a later step throws, the orchestrator walks back up the completed steps and calls the second function you passed to createStep, in reverse order, with the payload you stashed in StepResponse's second argument.

That is the whole contract, and it is honoured precisely. The problem is what people read into it.

Two details make the gap wider than it looks. First, a step created without a second function is not flagged as risky — createStep simply sets noCompensation = !compensateFn and moves on. The step becomes silently irreversible. Second, hooks are steps. createHook(name, input) compiles down to a createStep call with a no-op body that your handler replaces, so a hook handler's side effects sit on exactly the same side of the boundary as any other step's.

The simulator below walks completeCartWorkflow as it exists in v2.18.0. Pick where it fails and watch the two columns diverge:

InteractiveCompensation Boundary Simulatorlink

Run completeCartWorkflow step by step, choose where it fails, and see what compensation rolls back versus what it cannot: the already-emitted order.placed, the subscriber's ERP write, and the authorized payment.

Compensation Boundary Simulator

Pick the step where completeCartWorkflow fails. Everything above it already ran. Compensation walks back up the list — and stops dead at the process boundary.

Event bus state

Subscriber behaviour

The moment a subscriber makes an outbound call, the workflow has lost every lever it had over that effect.

Steps executed

9

failed at addOrderTransactionStep

Rolled back

5

database writes reverted

Stranded outside

4

effects past the boundary

Phantom order. The database is clean and the world is not.

Medusa deleted the order rows, so GET /admin/orders shows nothing. Your customer has a confirmation email with an order number that does not exist, and your ERP holds a sales order nobody can reconcile against. Support gets the call, not your error tracker.

order.placed delivered to subscribers

Compensation called clearGroupedEvents(); the call threw. The SDK only logs a warning, so the message stays queued and is delivered anyway.

Confirmation email sent to the customer

The subscriber left the Node process the moment it called the mail provider. Nothing in the Workflows SDK has a handle on it.

Sales order posted to the ERP

The order id now exists in a system that has no idea the Medusa row was deleted. That is the phantom: a downstream document with no upstream record.

Payment authorized at the provider

Compensation can cancel or refund — but that is a brand-new money movement with its own failure modes, not a rollback.

5 in-process writes reverted

acquireLockStep, createOrdersStep, createRemoteLinkStep, updateCartsStep, reserveInventoryStep — each of these shipped a compensation function, so the SDK could undo them in reverse order. This part works exactly as documented.

How do you Case 1 — order.placed goes out before the payment is authorized?

Status: reported as #15122, closed not_planned on 24 May 2026 by a stale bot — not by a maintainer decision — and still present in v2.18.0.

In packages/core/core-flows/src/cart/workflows/complete-cart.ts at tag v2.18.0, emitEventStep for OrderWorkflowEvents.PLACED sits at line 648, inside the parallelize() block alongside createRemoteLinkStep, updateCartsStep, reserveInventoryStep and registerUsageStep. authorizePaymentSessionStep and addOrderTransactionStep run after that block. The ordering is exactly what the issue described; the line number drifted from roughly 594 at report time to 648 at v2.18.0, because the code moved and the bug did not.

The maintainer bot on the thread agreed with the diagnosis and named the fix — move the emit out of parallelize() so it runs after addOrderTransactionStep — then invited a community contribution. Nobody took it. Thirty-eight days later the stale bot closed the issue. It is worth being precise about that: this was not triaged and rejected, it was left alone until an automation swept it up.

"But events are buffered until the workflow succeeds." That is the design intent, and the docs say so. It is true on the happy path. It fails three ways in production:

  1. The buffer is best-effort, not transactional. emitEventStep's compensation calls eventBus.clearGroupedEvents(eventGroupId, { eventNames }). The release-and-clear logic in workflow-export.ts wraps those calls in a .catch() that only writes a logger.warn. A failed clear on the compensation path means the message stays queued and is delivered anyway. The reporter's own log line was a failure to release grouped events.
  2. eventGroupId is only auto-set on the outermost .run(). The code does context.eventGroupId ??= uniqId. If it is absent, emitEventStep's compensation returns early — a no-op — and the event went out ungrouped, meaning immediately.
  3. Grouping defers the emit, never the subscriber's side effects. Once released, a subscriber that calls Stripe, your mail provider, or your ERP has left the process. Nothing in the SDK can reach it.

That third point is the whole article. The reporter's compensation failure was in create-remote-links — a step inside the same parallelize() block as the emit. The workflow returned an order to the caller, ran a compensation pass, and order.placed had already gone out.

How do you Case 2 — the refund workflow reports success after a partial failure?

Status: reported as #15306, closed not_planned on 12 June 2026. The fix, PR #15307, was closed unmerged.

packages/core/core-flows/src/payment/steps/refund-payments.ts is 71 lines at v2.18.0 and the step body is 27 of them. It pushes each paymentModule.refundPayment(refundInput) into an array with a .catch() that logs and returns nothing, then filters the settled results with isObject. Three consequences follow mechanically:

  • The rejected promise is converted into a resolved void, so the step cannot fail.
  • The void results are filtered out of existence — no count, no ids, no aggregate error.
  • There is no compensation function at all. createStep is called with two arguments, so a downstream failure cannot even attempt to reconcile.

A fourth, smaller one: the log line says Error was thrown trying to cancel payment for a refund operation, so grepping production logs for refund misses these entirely.

And it is worse than the issue says. This part is our own reading of the v2.18.0 source, not something the issue reports: in packages/core/core-flows/src/payment/workflows/refund-payments.ts, the step's result is bound to refundedPayments, but the transform that builds orderTransactionData closes over { payments, input } — the requested refunds — and not over refundedPayments. addOrderTransactionStep then writes a negative order transaction for every refund you asked for, including the ones that threw at the provider. The workflow returns 200. The money never moved. Reconciliation shows the order as refunded.

PR #15307 proposed the right shape: return { refunded_payments, failed_refunds }, create transactions only for successes, and emit a refund-failed event on partial failure, with unit tests. The changeset bot flagged it as a major bump across 78 packages, which is a plausible reason it stalled — changing a public step's return type is a breaking change. It was closed unmerged. Do not wait for a patch here; wrap the workflow yourself.

How do you Case 3 — retrying a capture sends a different idempotency key?

Status: #16292 is OPEN, labelled requires-team, filed 3 August 2026. The companion PR #16293 is open and unmerged.

In PaymentModuleService, the public capturePayment calls capturePayment_ to mint a capt_... row, then calls capturePaymentFromProvider_, and on any error deletes the capture row and rethrows. capturePaymentFromProvider_ derives the provider idempotency key from that row: context: { idempotency_key: capture?.id }. The Stripe adapter forwards context.idempotency_key as Stripe's idempotencyKey at eleven call sites.

Stated precisely: the deduplication token is the primary key of a row created immediately before the provider call and destroyed immediately after a failed one. It has the lifetime of a single attempt, so it can never dedupe an attempt — which is the only thing an idempotency key is for.

The failure window is the ordinary one. Stripe receives and processes the capture; the response is lost to a timeout, a connection reset, a pod eviction. Medusa's catch deletes capt_A. A retry — a human in Admin, a step's maxRetries, a client re-POST — runs capturePayment_ again, creates capt_B, and sends capt_B as the key. Stripe sees a brand-new key for a brand-new request, and captures the funds a second time. Stripe's contract is keyed strictly on the string you send.

The same file gets this right everywhere else. Other capture-adjacent calls derive the key from long-lived ids — the payment session id, the payment id, the refund id. Capture is the one that uses an ephemeral id.

Do not confuse this with #16012, which is a different, adjacent bug that was fixed and closed as completed. Its fix is plainly visible in v2.18.0's capturePayment_ as a SET LOCAL lock_timeout plus SELECT ... FOR UPDATE critical section that serialises concurrent captures on the same payment row. That fix is correct and it landed. The comment above it is proud that "the lock is never held across the provider call" — which is correct engineering for database contention, and is also, exactly, the boundary where compensation stops working. Medusa fixed the race inside the transaction. The idempotency problem sits outside it and is still open. That contrast is the thesis of this article in one repository diff.

Where should the side effect go instead?

There are two candidate homes for a downstream trigger, and only one of them survives contact with a compensation pass. The comparator walks six real failure windows:

InteractiveSafe Event Timing Comparatorlink

Mid-flow emitEventStep versus a terminal-state subscriber and .run({ events: { onFinish } }), scored across six real failure windows including a bus blip, a missing eventGroupId, and a core route you do not own.

Safe Event Timing Comparator

Six real failure windows. Pick one to see which of the two patterns actually closes it — a mid-flow emitEventStep, or a subscriber that re-reads terminal state (with .run({ events: { onFinish } }) where you own the call site).

Mid-flow emitEventStep

0/6

windows closed

Terminal-state subscriber

5/6

windows closed

A step after the emit fails and the workflow compensates

This is medusajs/medusa#15122 exactly: emitEventStep(order.placed) sits inside parallelize() at line 648, authorizePaymentSessionStep and addOrderTransactionStep run afterwards.

Mid-flow emitEventStep

Grouped events help on the happy path only. The clear is best-effort, and anything a subscriber already did is gone.

Terminal-state subscriber

The subscriber re-reads the order and requires transactions.length > 0 before it acts. A compensated order fails that gate.

Two API facts matter here, and both are easy to get wrong.

There is no onFinish option on createWorkflow. Its config object is TransactionModelOptions and nothing else: timeout, store, retentionTime, storeExecution (deprecated), idempotent, schedule. If you have seen an onFinish in a createWorkflow call somewhere, it was invented.

There is an onFinish on .run(), typed as part of DistributedTransactionEvents, alongside onBegin, onStepSuccess, onCompensateBegin and the rest. It is real and useful — with a genuinely surprising caveat. The SDK wraps your onFinish in attachOnFinishReleaseEvents, and that wrapper returns early without calling you when the transaction state is FAILED or REVERTED. So with an Event Bus module registered, your onFinish fires only on success. Without an event bus, the wrapper calls it unconditionally, including on failures. Never put cleanup logic there.

And note the scope limit: events is per-call. It is not a way to attach a terminal hook to a core workflow you do not invoke yourself. POST /store/carts/:id/complete runs completeCartWorkflow for you; you never own that call site. For that case, the subscriber gate is the only pattern that works.

Code recipeWorkflow Safety Recipeslink

Three copyable recipes using only v2.18.0-verified API: a subscriber that re-reads terminal state before acting, an idempotent workflow keyed on a deterministic transactionId, and a step whose compensation reconciles an external call instead of blindly reversing it.

Workflow Safety Recipes

Every API name, option and type signature below is read from the tagged v2.18.0 source or an official docs page. The compositions themselves are illustrative — they are not executed in CI, and erpClient stands in for whatever HTTP client you register.

ts
// src/subscribers/order-placed-erp-sync.ts
// Do NOT treat order.placed as proof the checkout succeeded.
// It is emitted from inside parallelize() in completeCartWorkflow,
// before authorizePaymentSessionStep — medusajs/medusa#15122.

import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import { syncOrderToErpWorkflow } from "../workflows/sync-order-to-erp"

export default async function orderPlacedHandler({
  event: { data },
  container,
}: SubscriberArgs<{ id: string }>) {
  const query = container.resolve(ContainerRegistrationKeys.QUERY)

  // Re-read terminal state. The payload is { id } only — it carries
  // no proof that the transaction reached a successful terminal state.
  const { data: [order] = [] } = await query.graph({
    entity: "order",
    fields: ["id", "status", "transactions.id"],
    filters: { id: data.id },
  })

  // addOrderTransactionStep is the last meaningful write of
  // completeCartWorkflow. No transactions => not a placed order,
  // no matter what the event said.
  if (!order || !order.transactions?.length) {
    return
  }

  await syncOrderToErpWorkflow(container).run({
    input: { order_id: order.id },
    // transactionId lives in `context` — there is no top-level
    // transactionId on FlowRunOptions.
    context: { transactionId: `erp-sync:${order.id}` },
  })
}

export const config: SubscriberConfig = {
  event: "order.placed",
}

// ---------------------------------------------------------------
// Where you DO own the call site, .run() takes lifecycle events.
// There is NO onFinish option on createWorkflow — this is the only
// onFinish that exists, and it is typed as DistributedTransactionEvents.
//
//   const { result, errors } = await myWorkflow(req.scope).run({
//     input,
//     throwOnError: false,
//     events: {
//       onFinish: async ({ transaction, errors }) => {
//         if (errors?.length) return
//         // terminal side effect goes here
//       },
//     },
//   })
//
// Caveat: attachOnFinishReleaseEvents wraps your onFinish and returns
// early on FAILED / REVERTED when the Event Bus module is registered.

A note on honesty, since this post is about precision: every API name, option and type signature in those recipes is read from the tagged v2.18.0 source or from an official docs page. The example workflows themselves are illustrative compositions written for this article. They are not executed in CI, and erpClient stands in for whatever HTTP client you register in your container.

Inventory the steps that leave the process

Walk your workflow and mark every step and hook that emits an event, calls an HTTP API, sends mail, or moves money. Steps that only write to Postgres through a module service are the easy case — give them a compensation function and you are done. Everything else needs the treatment below.

Move downstream triggers into a subscriber

Register a subscriber on order.placed, but do not trust the payload — it is { id } and carries no proof of terminal success. Re-read the order with query.graph and require transactions.length > 0, which is written by addOrderTransactionStep, the last meaningful step of completeCartWorkflow. A compensated order fails that gate.

Pin a deterministic transaction id

Pass context: { transactionId: "..." } on .run(), derived from your business key. There is no transactionId or idempotencyKey field on FlowRunOptions itself — it lives inside context. Then add store: true and idempotent: true to your workflow's config for only-once execution, keyed on that id. A random transaction id defeats the entire mechanism.

Derive the external key from something that outlives the attempt

Build the provider idempotency key as context.transactionId plus the entity id. Persist it before the call. Never delete it in a catch block — that is the capturePayment_ bug reproduced in your own code.

Make compensation reconcile

In the compensation function, look the record up by idempotency key first. If the provider never saw it, return — there is nothing to undo. If it did, issue a semantic reversal with its own derived key. An ERP posting is not deletable; reverse it rather than pretending to erase it.

Set the retry options deliberately

maxRetries on a step that calls an external API without a stable key turns one lost response into several real charges. Set retry options either in createStep({ name, maxRetries, retryInterval }, ...) or with .config({ ... }) on the step invocation — and read the reference below before you set retryInterval, which quietly makes the workflow long-running.

InteractiveWorkflow Options Referencelink

Every workflow-scoped and step-scoped option verified against v2.18.0 source: idempotent, store, retentionTime, maxRetries, autoRetry, retryInterval, retryIntervalAwaiting, async, backgroundExecution and compensateAsync, with what each changes and what bites.

Workflow Options Reference

The options that actually exist at v2.18.0. Workflow-scoped ones go in createWorkflow({ name, ... }); step-scoped ones go in createStep({ name, ... }) or myStep(input).config({ ... }). There is no onFinish among them.

idempotentbooleanworkflow-scoped

What it changes

Uses the transaction ID as the key to ensure only-once execution. Forces checkpointing on inside the orchestrator.

What bites

Worthless with an auto-generated transaction id. Pass a deterministic one via context.transactionId, derived from a business key.

createWorkflow(
  { name: "my-workflow", idempotent: true },
  (input) => { /* ... */ }
)

What you cannot fix from userland

Being straight about the limits is more useful than a pattern that pretends they are not there.

You cannot reorder emitEventStep inside completeCartWorkflow — it is core-flows code. Your options are the orderCreated hook, which is better but still a step with releaseLockStep after it; the subscriber gate, which is the only genuinely safe one; or a fork.

You cannot make refundPaymentsStep fail. You can wrap refundPaymentsWorkflow in your own workflow and add a following step that re-reads the payments and throws if the refunds do not match what you asked for.

You cannot fix the capture idempotency key at all without writing your own payment provider that maintains its own key store — the key is minted in PaymentModuleService before your provider is ever called. Watch PR #16293.

And you will struggle to write an integration test that proves any of this. #15836 reported that medusaIntegrationTestRunner's afterEach returns before event-triggered workflows finish: the wait loop breaks the moment zero non-terminal executions exist, with no settle window, while events are released after the transaction is already done and the subscriber's own workflow row has not been inserted yet. It was closed not_planned on 15 July 2026. Give your test workflows a retentionTime so their execution rows survive as observable done rows, and assert on the follow-on workflow's effect rather than relying on the runner's teardown.

One more piece of honesty about the state of the field: outside GitHub, there is almost nothing written about this. We looked. The only substantive independent write-up we could find is a dev.to post by David Bartalos whose third bug is a stale workaround that kept re-emitting order.placed after the upstream fix landed, producing duplicate confirmation emails — real-world evidence that order.placed gets treated as a trigger for irreversible side effects. There is no Stack Overflow cluster and no Reddit thread on Medusa compensation boundaries. There is also no dedicated idempotency page in the Medusa docs; the surface is scattered across the store-executions and long-running-workflow pages and a TSDoc comment in source. That documentation gap is a legitimate reason developers get this wrong.

WhyWhat to do instead
Rows written by a module service in a stepYes, if you wrote the compensation functionEach step commits on its own; the SDK calls your reversal in reverse orderAlways pass a second function to createStep
Event emitted with emitEventStepBest-effort onlyCompensation calls clearGroupedEvents; no eventGroupId means a no-op, and a failed call only logs a warningEmit from a terminal state, and gate the subscriber on re-read evidence
Work a subscriber already did on a released eventNeverDifferent execution context; the workflow holds no handle on itRe-read the entity in the subscriber before acting
Emails, SMS, webhooks, Slack messagesNeverOut of process the instant the call succeedsSend only from a subscriber gated on terminal state
A Stripe capture or chargeOnly via an explicit refund you wroteA refund is a new money movement with its own failure modes, not an undoStable idempotency key; reconcile before reversing
An ERP, PIM or WMS writeNever automaticallyMost ERPs treat a reversal as a new posting, not a deletionWrite a reversing document keyed off the same idempotency key
The capt_ row on a failed captureIt is deleted — and that is the bugDeleting it destroys the retry's idempotency key (#16292)Nothing from userland; watch PR #16293

What Medusa v2 compensation can and cannot undo, and the safe alternative for each

How do you At a glance — quick reference?

AspectWhat to know
When to use Medusa v2 Workflow CompensationStandard fit for the common case; review edge cases against the table.
Typical effort1–4 hours for a small team; longer with custom data or multi-entity setups.
Main riskSkipping reconciliation or running before the data is clean.
What to do nextRun the steps below, then verify against the checklist.

Frequently asked questions

+Does the Medusa v2 compensation function roll back an emitted event?

Only best-effort. emitEventStep's compensation calls eventBus.clearGroupedEvents(eventGroupId, { eventNames }), which needs a live eventGroupId and a reachable event bus. If the eventGroupId is missing, the compensation returns early and does nothing. If the bus call throws, the SDK catches it and logs a warning, so the message stays queued and is delivered anyway. And nothing at all rolls back work a subscriber has already done — that work left the Node process.

+Why does order.placed fire before authorizePaymentSessionStep in completeCartWorkflow?

Because emitEventStep({ eventName: OrderWorkflowEvents.PLACED }) sits inside the parallelize() block at line 648 of packages/core/core-flows/src/cart/workflows/complete-cart.ts, while authorizePaymentSessionStep and addOrderTransactionStep run afterwards. It was reported as medusajs/medusa#15122 and the diagnosis was confirmed on the thread, but the issue was closed not_planned by a stale bot rather than by a maintainer decision, and the ordering is unchanged in v2.18.0.

+Which Medusa version fixes the refund workflow reporting success on a partial refund failure?

None. As of v2.18.0, released 23 July 2026 and the latest at the time of writing, refundPaymentsStep still catches each rejection, logs it, and filters the resulting undefined out with isObject. Issue #15306 was closed not_planned and the fix PR #15307 was closed unmerged — most likely because changing the step's public return shape is a breaking change across the monorepo. Do not wait for a patch; wrap refundPaymentsWorkflow and assert on the result yourself.

+Is there an onFinish or terminal hook on createWorkflow in Medusa v2?

Not on createWorkflow. Its config object is TransactionModelOptions, whose only fields are timeout, store, retentionTime, storeExecution (deprecated), idempotent and schedule. But .run({ events: { onFinish } }) is real, typed as part of DistributedTransactionEvents. The caveat is that when the Event Bus module is registered, the SDK's wrapper does not call your onFinish on a FAILED or REVERTED transaction — and without an event bus it calls it unconditionally.

+How do I get a stable idempotency key inside a Medusa workflow step?

Use transactionId from the step execution context, and pin it deterministically with .run({ context: { transactionId: "..." } }) derived from a business key. There is no transactionId or idempotencyKey field on FlowRunOptions itself — it goes inside context. For only-once semantics, add idempotent: true together with store: true to createWorkflow's config object. The step context also exposes attempt, which is the only first-class signal that a step is being retried.

+Hey Google, why does my Medusa order confirmation email get sent even though the checkout failed?

Because the email subscriber is listening to order.placed, and that event is emitted from the middle of completeCartWorkflow — before the payment is authorized and before the order's transactions are recorded. If a later step fails and the workflow compensates, the order rows are rolled back but the email has already gone. Fix it by gating the subscriber on a terminal state you re-read yourself, rather than trusting the event payload.

Can Medusa workflows undo a Stripe charge automatically if a later step fails?. No. A workflow can only run a compensation function you wrote, and the only way to reverse a Stripe charge is to issue a refund — which is a brand-new money movement that can itself fail. Medusa gives you the saga pattern, not distributed transactions. Assume every external call is permanent the moment it succeeds, and design the reversal as a deliberate business action rather than an undo.

What happens if we do not fix this before going live?. The failure is quiet and it lands on your support team, not your error tracker. Customers get confirmation emails for orders that no longer exist. Your ERP accumulates sales orders with no matching Medusa record, so month-end reconciliation does not close. Refunds show as completed in the ledger while the money is still with you, which is the kind of discrepancy that becomes an audit finding. And a lost provider response during a capture retry can charge a customer twice. None of these throw an exception you can alert on — that is precisely why they are worth an afternoon before launch.

Compensation is in-process bookkeeping. The instant a step's effect leaves the Node process — an event released to a subscriber, an HTTP call to a payment provider, a write to your ERP — the SDK has no lever on it, and every one of the three bugs above is that single sentence wearing a different hat. Draw the boundary on your own workflow diagram, put your irreversible calls on the far side of a terminal-state gate, and give every one of them a key that outlives the attempt.

Wiring Medusa to an ERP or accounting system?

We build the integration layer between Medusa and ERPNext, Tally and accounting stacks — including the idempotency and reconciliation work that keeps a failed checkout from becoming a phantom document downstream.

Now work out what a headless build costs you

Price a Medusa build against what you pay now for Shopify or WooCommerce — platform fees, apps and transaction cuts included, over three years.

M

Written by

Manoj

Founder of Mith Tech, an open-source ERP & automation studio. Hands-on ERPNext/Frappe implementation across multi-branch, multi-warehouse Indian operations — GST/TDS/PT compliance, branch-level permissions, and custom Frappe apps that give management real-time visibility.

Free · By email

Get practical ERPNext & automation guides

New implementation guides, cost breakdowns and open-source tips for Indian businesses — occasionally, straight to your inbox. No spam.

Already a Mith Tech client?

Help the next operator choose.

Most teams evaluating ERPNext have no way to tell who actually delivers. If we’ve run an implementation for you, two lines on Google count for more than anything we can write about ourselves.

Leave a Google review

Only if we’ve actually worked together — Google filters reviews from non-customers, so an honest one is worth more than ten polite ones.

Keep reading

See what this looks like for your business

A 30-minute working session with a principal consultant. We pressure-test the architecture and outline the engagement model that fits your governance and procurement posture. You leave with a written brief.

0
Published on 5 August 2026

Manoj

Comments & ratings

No comments yet. Start a new discussion.