Step 6: Send paid orders
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. Later tidied in refactor: call frappe's request helpers directly and parse replies with parse_json, refactor: use frappe utilities across the app and refactor(events): read the order from event.reference_name. 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_orderfunction that sends one order, once. - A handler for
order_paidthat 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. It runs after install and after each migrate.
"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:
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
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.
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
The cart check in 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:
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:
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. The sandbox does not filter orders by number, so find_order reads the 10 newest pages of GET /api/order and matches the number:
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.
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.
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.
5. Listen to the paid event
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:
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.
Check it works
- Run
bench --site <site> migrate. - On the storefront, buy a mapped T-shirt with a test card.
- 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, the hourly sync replaces Sent with Qikink's own status, such as On Hold.
- In
bench --site <site> console, check that Qikink has it. Use your order's number in place of202600077:
from commera_qikink.client import Qikink
Qikink().find_order("202600077")