---
title: "Step 6: Send paid orders"
space: "Commera"
url: "https://docs.bwh.tech/commera/build-a-plugin/send-paid-orders"
updated: "2026-10-09"
---

In this step, your plugin sends each paid store order to Qikink. It listens to the `order_paid` event, builds the Qikink payload from the order, and saves the Qikink order number on the Sales Order. A retry never makes a second Qikink order.

Code for this step: [feat: send paid orders to qikink once](https://github.com/Rl0007/commera_qikink/commit/2944b7f). Later tidied in [refactor: call frappe's request helpers directly and parse replies with parse_json](https://github.com/Rl0007/commera_qikink/commit/4900622), [refactor: use frappe utilities across the app](https://github.com/Rl0007/commera_qikink/commit/a8fa754) and [refactor(events): read the order from event.reference_name](https://github.com/Rl0007/commera_qikink/commit/025ce0e). The code on this page is the tidied version.

## What you'll build

- Three read-only fields on Sales Order: **Qikink Order Number**, **Qikink Status** and **Sent to Qikink At**.
- A `send_order` function that sends one order, once.
- A handler for `order_paid` that calls it for prepaid orders.

## 1. Add the order fields

Add the fields to the `add_custom_fields` function that you wrote in [Map products](/commera/build-a-plugin/map-products). It runs after install and after each migrate.

```python
"Sales Order": [
	{
		"fieldname": "commera_qikink_order_number",
		"label": "Qikink Order Number",
		"fieldtype": "Data",
		"insert_after": "po_date",
		"read_only": 1,
		"no_copy": 1,
		"module": MODULE,
	},
	{
		"fieldname": "commera_qikink_status",
		"label": "Qikink Status",
		"fieldtype": "Data",
		"insert_after": "commera_qikink_order_number",
		"read_only": 1,
		"no_copy": 1,
		"module": MODULE,
	},
	{
		"fieldname": "commera_qikink_sent_at",
		"label": "Sent to Qikink At",
		"fieldtype": "Datetime",
		"insert_after": "commera_qikink_status",
		"read_only": 1,
		"no_copy": 1,
		"module": MODULE,
	},
],
```

Set `no_copy`. Without it, **Duplicate** in Desk copies the Qikink number to the new order, and your plugin thinks that the new order was sent.

## 2. Make the order number

Qikink takes an order number of at most 15 characters from `a-z`, `0-9` and `_`. Use the digits of the Sales Order name. `SAL-ORD-2026-00062` becomes `202600062`.

Create `commera_qikink/orders.py`:

```python
import re

import frappe
from frappe import _
from frappe.utils.data import cstr

MAX_ORDER_NUMBER_LENGTH = 15


def get_order_number(sales_order: str) -> str:
	order_number = re.sub(r"\D", "", cstr(sales_order))
	if not order_number:
		frappe.throw(_("Order {0} has no digits to use as a Qikink order number.").format(sales_order))
	if len(order_number) > MAX_ORDER_NUMBER_LENGTH:
		frappe.throw(
			_("A Qikink order number has at most {0} digits, and order {1} gives {2}.").format(
				MAX_ORDER_NUMBER_LENGTH, sales_order, order_number
			)
		)
	return order_number
```

:::caution[Never truncate the order number]

Two orders that share their first 15 digits would get the same Qikink number. Qikink refuses the second as a duplicate, or your plugin adopts the wrong order. Throw instead, and change the naming series.

:::

## 3. Send the order once

`send_order` locks the Sales Order row. The paid event and a staff click can then not both get past the "already sent" check.

```python
from commera_qikink.client import Qikink, QikinkError
from frappe.utils.data import now_datetime

SENDING_STATUS = "Sending"
SENT_STATUS = "Sent"


def send_order(sales_order: str) -> str | None:
	order = frappe.get_doc("Sales Order", sales_order, for_update=True)
	if order.docstatus != 1:
		frappe.throw(_("Submit order {0} before sending it to Qikink.").format(sales_order))
	if order.commera_qikink_order_number:
		return order.commera_qikink_order_number

	lines = get_qikink_lines(order)
	if not lines:
		return None
	submit_to_qikink(order, lines)
	return order.commera_qikink_order_number


def submit_to_qikink(order, lines: list[tuple]):
	order_number = get_order_number(order.name)
	payload = get_order_payload(order, lines, order_number)
	client = Qikink()
	if not (order.commera_qikink_status == SENDING_STATUS and client.find_order(order_number)):
		order.db_set("commera_qikink_status", SENDING_STATUS, update_modified=False)
		frappe.db.commit()
		create_order(client, payload)

	order.db_set(
		{
			"commera_qikink_order_number": order_number,
			"commera_qikink_status": SENT_STATUS,
			"commera_qikink_sent_at": now_datetime(),
		},
		update_modified=False,
	)
	frappe.db.commit()


def create_order(client: Qikink, payload: dict):
	try:
		client.create_order(payload)
	except QikinkError as error:
		if "duplicate order" not in cstr(error).lower() or not client.find_order(payload["order_number"]):
			raise
```

:::danger[Never send an order twice]

Qikink has no cancel API. A second create means a second printed T-shirt. The code above guards in three places:

1. It commits the **Sending** status before the call. If the worker stops after Qikink accepts the order, the retry sees **Sending** and looks for the order first.
2. If Qikink already lists the order number, the retry adopts that order and does not send it again.
3. Qikink answers a known number with `Duplicate Order.`. The code treats this as success only when it finds the order.

Qikink can accept an order and then answer with a body that is not JSON. The call raises, the status stays **Sending**, and the next retry finds the order and adopts it. In [Create drop-ship orders](/commera/build-a-plugin/create-drop-ship-orders), you give that reply its own error message.

:::

The cart check in [Block unmapped carts](/commera/build-a-plugin/block-unmapped-carts) queried Items that Qikink delivers and that have no SKU. Move that query to `commera_qikink/items.py` as `get_qikink_items(item_codes)`. It reads the Qikink supplier itself with `frappe.db.get_single_value("Qikink Settings", "supplier")`, so no caller repeats that read. Drop the "no SKU" filter, and return a dict of rows keyed by item code, with `commera_qikink_sku` and `commera_qikink_plain_product`. The cart check and the order code then share it. `get_unmapped_item_refusal` in `cart.py` becomes:

```python
def get_unmapped_item_refusal(quotation) -> str | None:
	item_names = {row.item_code: row.item_name for row in quotation.items}
	qikink_items = get_qikink_items(list(item_names))
	unmapped = [item_code for item_code, item in qikink_items.items() if not item.commera_qikink_sku]
	if not unmapped:
		return None
	return _("{0} can't be ordered right now. Please remove it from your cart to check out.").format(
		", ".join(sorted(item_names[item_code] for item_code in unmapped))
	)
```

`get_qikink_lines` pairs each order line with its Qikink item, and refuses a line without a SKU:

```python
def get_qikink_lines(order) -> list[tuple]:
	qikink_items = get_qikink_items([row.item_code for row in order.items])
	lines = [(row, qikink_items[row.item_code]) for row in order.items if row.item_code in qikink_items]
	if unmapped := sorted({row.item_name for row, item in lines if not item.commera_qikink_sku}):
		frappe.throw(
			_("Add a Qikink SKU to {0} before sending this order to Qikink.").format(", ".join(unmapped))
		)
	return lines
```

Add `create_order` and `find_order` to the client from [Connect to Qikink](/commera/build-a-plugin/connect-to-qikink). The sandbox does not filter orders by number, so `find_order` reads the 10 newest pages of `GET /api/order` and matches the number:

```python
def create_order(self, payload: dict) -> dict:
	return self.request(make_post_request, "/api/order/create", json=payload)

def find_order(self, order_number: str) -> dict | None:
	for page_no in range(1, MAX_ORDER_PAGES + 1):
		orders = self.request(make_get_request, "/api/order", params={"page_no": page_no})
		if not orders:
			return None
		for order in orders:
			if get_order_number(order) == order_number:
				return order
	return None
```

Qikink returns the number with your account number in front, for example `654367_202600062`. The module-level `get_order_number(order)` in `client.py` keeps the part after the first `_`.

## 4. Build the payload

Read the shopper and the address with `commera.sdk.orders.get_order`. Read the lines from the Sales Order.

```python
from commera.sdk import orders as commera_orders
from frappe.utils.data import cint, flt

COD_PAYMENT_MODE = "COD"


def get_order_payload(order, lines: list[tuple], order_number: str) -> dict:
	commera_order = commera_orders.get_order(order.name)
	return {
		"order_number": order_number,
		"qikink_shipping": 1,
		"gateway": get_gateway(commera_order),
		"total_order_value": get_order_value(order, lines),
		"line_items": [get_line_item(row, item) for row, item in lines],
		"shipping_address": get_shipping_address(commera_order),
	}


def get_gateway(order: dict) -> str:
	return "COD" if order["payment_mode"] == COD_PAYMENT_MODE and not order["is_paid"] else "Prepaid"


def get_line_item(row, item) -> dict:
	line_item = {"sku": item.commera_qikink_sku, "quantity": cint(row.qty), "price": flt(row.rate)}
	if item.commera_qikink_plain_product:
		return {"search_from_my_products": 0, "print_type_id": 1, **line_item}
	return {"search_from_my_products": 1, **line_item}
```

`get_shipping_address` maps `commera_order["shipping_address"]` to Qikink's keys: `first_name`, `last_name`, `address1`, `address2`, `phone`, `email`, `city`, `zip`, `province` and `country_code`. Throw when a required value is empty. Qikink takes 90 characters in `address1`, so move the rest to `address2` at a word break. The commit has the full function.

:::caution[COD or Prepaid]

`gateway` tells Qikink's courier whether to collect cash. Send `COD` only while the order is unpaid. After Commera records the cash, the order is prepaid, and the courier must not collect it again.

:::

`get_order_value` returns what the shopper paid for the Qikink lines alone: their net amount plus their share of each percentage tax. It leaves out "Actual" charges, such as shipping, the COD fee and gift wrap. Those belong to the whole order.

:::caution[Qikink gets only its lines' value]

A cart can hold a Qikink T-shirt and a planter from your own stock. Qikink must not see the planter's price. On a COD order, this means two collections: Qikink's courier collects the T-shirt's value, and your own parcel collects the rest.

:::

## 5. Listen to the paid event

```python
def on_order_paid(event):
	if event.data.get("payment_mode") == COD_PAYMENT_MODE:
		return
	send_order(event.reference_name)
```

Register it in `hooks.py`:

```python
commera_events = {"order_paid": ["commera_qikink.orders.on_order_paid"]}
```

A COD order fires `order_paid` only after its cash is collected. Your staff send COD orders by hand. You build that action in [Order card and actions](/commera/build-a-plugin/order-card-and-actions).

:::tip

When `send_order` raises, Commera rolls back, writes an Error Log and delivers the event again later. Read [Events](/commera/plugin-reference/events) for the retry delays.

:::

## Check it works

1. Run `bench --site <site> migrate`.
2. On the storefront, buy a mapped T-shirt with a test card.
3. Open the order in Desk. **Qikink Order Number** shows the digits of the order name, **Qikink Status** shows **Sent** and **Sent to Qikink At** shows the time. After [Keep status in sync](/commera/build-a-plugin/keep-status-in-sync), the hourly sync replaces **Sent** with Qikink's own status, such as **On Hold**.
4. In `bench --site <site> console`, check that Qikink has it. Use your order's number in place of `202600077`:

```python
from commera_qikink.client import Qikink

Qikink().find_order("202600077")
```

<img src="/files/commera-qk-so-sent-fields-07590cdd.png" alt="The Sales Order in Desk with the Qikink Order Number, Qikink Status and Sent to Qikink At fields" width="975">

## Next steps

- [Step 7: Create drop-ship orders](/commera/build-a-plugin/create-drop-ship-orders)
- [Events](/commera/plugin-reference/events)
- [SDK](/commera/plugin-reference/sdk)
