Orders & Checkout API
What happens when you tap "Place order"
Transactions in 2 minutes
A transaction groups several queries so that either all of them happen or none. Imagine the power goes off after the stock was reduced but before the order was saved — without a transaction, one watch would simply disappear.
<?php
$pdo = db();
$pdo->beginTransaction(); // from here, nothing is final
try {
$pdo->prepare('UPDATE products SET stock = stock - 1 WHERE id = 7')->execute();
$pdo->prepare('INSERT INTO orders (…) VALUES (…)')->execute(array(/* … */));
$pdo->commit(); // both changes become real together
} catch (Exception $e) {
$pdo->rollBack(); // undo BOTH, as if nothing happened
}The last-piece problem
Only one Cricket Bat is left. Riya and Aman tap "Place order" in the same second. Without locking, both see stock = 1 and both orders succeed. SELECT … FOR UPDATE makes the second request wait until the first one commits:
Step 1: orders/place.php
Notice that prices are calculated again with cart_summary() inside the transaction — the order uses the server's numbers, and only Cash on Delivery is accepted for now.
<?php
// POST {address_id, coupon?, payment_method?}
// Turns the cart into an order inside ONE database transaction:
// lock products -> check stock -> create order + items -> reduce stock -> empty cart.
require_once __DIR__ . '/../../includes/helpers.php';
require_once __DIR__ . '/../../includes/cart.php';
require_once __DIR__ . '/../../includes/address.php';
require_method('POST');
$user = require_auth();
$addressId = in_int('address_id');
$coupon = in_str('coupon');
$payment = strtoupper(in_str('payment_method', 'COD'));
if ($payment !== 'COD') {
fail('Only Cash on Delivery is available right now.', 422);
}
$stmt = db()->prepare('SELECT * FROM addresses WHERE id = ? AND user_id = ?');
$stmt->execute(array($addressId, $user['id']));
$address = $stmt->fetch();
if (!$address) {
fail('Please select a delivery address.', 422);
}
$pdo = db();
$pdo->beginTransaction();
try {
// FOR UPDATE locks these product rows until COMMIT, so two buyers
// cannot both buy the last piece.
$rows = $pdo->prepare(
'SELECT c.product_id, c.qty, p.name, p.image, p.price, p.stock, p.is_active
FROM cart_items c JOIN products p ON p.id = c.product_id
WHERE c.user_id = ? FOR UPDATE'
);
$rows->execute(array($user['id']));
$items = $rows->fetchAll();
if (count($items) === 0) {
throw new RuntimeException('Your cart is empty.');
}
foreach ($items as $it) {
if ((int) $it['is_active'] !== 1) {
throw new RuntimeException($it['name'] . ' is no longer available. Please remove it from the cart.');
}
if ((int) $it['stock'] < (int) $it['qty']) {
throw new RuntimeException('Only ' . (int) $it['stock'] . ' left of ' . $it['name'] . '.');
}
}
$sum = cart_summary($user['id'], $coupon);
if ($coupon !== '' && $sum['coupon_code'] === null) {
throw new RuntimeException($sum['coupon_message']);
}
$orderNo = 'KE' . date('ymd') . strtoupper(substr(bin2hex(random_bytes(4)), 0, 6));
$pdo->prepare(
'INSERT INTO orders (order_no, user_id, ship_name, ship_phone, ship_address, subtotal, discount,
delivery_fee, total, coupon_code, payment_method)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
)->execute(array(
$orderNo, $user['id'], $address['name'], $address['phone'], address_text($address),
$sum['subtotal'], $sum['discount'], $sum['delivery_fee'], $sum['total'], $sum['coupon_code'], $payment,
));
$orderId = (int) $pdo->lastInsertId();
$addItem = $pdo->prepare('INSERT INTO order_items (order_id, product_id, name, image, price, qty) VALUES (?, ?, ?, ?, ?, ?)');
$reduce = $pdo->prepare('UPDATE products SET stock = stock - ? WHERE id = ?');
foreach ($items as $it) {
$addItem->execute(array($orderId, $it['product_id'], $it['name'], $it['image'], $it['price'], $it['qty']));
$reduce->execute(array($it['qty'], $it['product_id']));
}
$pdo->prepare('DELETE FROM cart_items WHERE user_id = ?')->execute(array($user['id']));
$pdo->commit();
} catch (RuntimeException $e) {
$pdo->rollBack();
fail($e->getMessage(), 409);
}
ok(array('order_id' => $orderId, 'order_no' => $orderNo, 'total' => $sum['total']), 'Order placed successfully!');Order status flow
Can cancel
- placed
- confirmed
- stock goes back
Cannot cancel
- shipped
- delivered
- already cancelled
Step 2: list.php and detail.php
The list shows a small card per order: number, date, item count, first image, total and status. Sub-selects give the count and image in the same query. The detail adds all items, the address snapshot, the price breakdown and a timeline the app draws as tracking steps.
<?php
function order_summary_out($o)
{
return array(
'id' => (int) $o['id'],
'order_no' => $o['order_no'],
'total' => (float) $o['total'],
'status' => $o['status'],
'item_count' => isset($o['item_count']) ? (int) $o['item_count'] : 0,
'first_image' => isset($o['first_image']) ? img_url($o['first_image']) : null,
'created_at' => $o['created_at'],
'can_cancel' => in_array($o['status'], array('placed', 'confirmed'), true),
);
}<?php
require_once __DIR__ . '/../../includes/helpers.php';
require_once __DIR__ . '/../../includes/orders.php';
require_method('GET');
$user = require_auth();
$stmt = db()->prepare(
'SELECT o.*,
(SELECT SUM(qty) FROM order_items i WHERE i.order_id = o.id) AS item_count,
(SELECT image FROM order_items i WHERE i.order_id = o.id ORDER BY i.id LIMIT 1) AS first_image
FROM orders o WHERE o.user_id = ? ORDER BY o.id DESC'
);
$stmt->execute(array($user['id']));
$out = array();
foreach ($stmt->fetchAll() as $o) {
$out[] = order_summary_out($o);
}
ok($out);<?php
// GET api/orders/detail.php?id=5
require_once __DIR__ . '/../../includes/helpers.php';
require_once __DIR__ . '/../../includes/orders.php';
require_method('GET');
$user = require_auth();
$stmt = db()->prepare('SELECT * FROM orders WHERE id = ? AND user_id = ?');
$stmt->execute(array(q_int('id'), $user['id']));
$o = $stmt->fetch();
if (!$o) {
fail('Order not found.', 404);
}
$it = db()->prepare('SELECT product_id, name, image, price, qty FROM order_items WHERE order_id = ? ORDER BY id');
$it->execute(array($o['id']));
$items = array();
$count = 0;
foreach ($it->fetchAll() as $r) {
$count += (int) $r['qty'];
$items[] = array(
'product_id' => (int) $r['product_id'], 'name' => $r['name'], 'image' => img_url($r['image']),
'price' => (float) $r['price'], 'qty' => (int) $r['qty'], 'line_total' => money($r['price'] * $r['qty']),
);
}
// Timeline for the tracking UI
$steps = array('placed', 'confirmed', 'shipped', 'delivered');
$reached = array_search($o['status'], $steps, true);
$timeline = array();
foreach ($steps as $i => $s) {
$timeline[] = array('status' => $s, 'done' => $reached !== false && $i <= $reached);
}
$out = order_summary_out($o);
$out['item_count'] = $count;
$out['first_image'] = count($items) ? $items[0]['image'] : null;
$out['items'] = $items;
$out['ship_name'] = $o['ship_name'];
$out['ship_phone'] = $o['ship_phone'];
$out['ship_address'] = $o['ship_address'];
$out['subtotal'] = (float) $o['subtotal'];
$out['discount'] = (float) $o['discount'];
$out['delivery_fee'] = (float) $o['delivery_fee'];
$out['coupon_code'] = $o['coupon_code'];
$out['payment_method'] = $o['payment_method'];
$out['timeline'] = $timeline;
ok($out);Step 3: cancel.php
Cancel is also a transaction: lock the order row, check the status, mark it cancelled and add every item's quantity back to stock with one UPDATE … JOIN.
<?php
// POST {id} -> cancel an order that has not shipped yet and put the stock back.
require_once __DIR__ . '/../../includes/helpers.php';
require_method('POST');
$user = require_auth();
$id = in_int('id');
$pdo = db();
$pdo->beginTransaction();
$stmt = $pdo->prepare('SELECT status FROM orders WHERE id = ? AND user_id = ? FOR UPDATE');
$stmt->execute(array($id, $user['id']));
$status = $stmt->fetchColumn();
if ($status === false) {
$pdo->rollBack();
fail('Order not found.', 404);
}
if (!in_array($status, array('placed', 'confirmed'), true)) {
$pdo->rollBack();
fail('This order is already ' . $status . ' and cannot be cancelled.', 409);
}
$pdo->prepare("UPDATE orders SET status = 'cancelled' WHERE id = ?")->execute(array($id));
$pdo->prepare(
'UPDATE products p JOIN order_items i ON i.product_id = p.id
SET p.stock = p.stock + i.qty WHERE i.order_id = ?'
)->execute(array($id));
$pdo->commit();
ok(array('id' => $id, 'status' => 'cancelled'), 'Order cancelled.');Adding online payment later
KartEasy uses Cash on Delivery so you can run it without a merchant account. To add UPI/cards with a gateway such as Razorpay, the order is created first, then the gateway confirms the payment to your server:
Today (Cash on Delivery) With an online payment gateway (e.g. Razorpay)
---------------------------- ------------------------------------------------
app → orders/place.php app → orders/place.php (status = 'pending_payment')
status = 'placed' app → opens the gateway's checkout with order_no + total
gateway → your webhook.php (signature verified on server)
webhook.php → UPDATE orders SET status = 'placed', paid = 1The app can be modified. Only your server, after verifying the gateway's signature in the webhook, may set an order as paid.
