Commera

Commera

Open in ChatGPT
Ask ChatGPT about this page
Open in Claude
Ask Claude about this page

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_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. 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

  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, 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:
from commera_qikink.client import Qikink

Qikink().find_order("202600077")
The Sales Order in Desk with the Qikink Order Number, Qikink Status and Sent to Qikink At fields

Next steps

Last updated 3 days ago
Was this helpful?
Thanks!