Nexus Gateway
EnglishRomână
OpenAPI

The menu lists what a customer can choose. Your cart turns those choices into order lines, and your backend calculates the total that the gateway checks.

From choices to order lines

Each cart line becomes one item: the product ID, a quantity, and every option the customer selected as a group_id/option_id pair. Include all choices: the size, the included drink and any extras. Do not send prices, variant IDs or names.

For example, a customer orders two large burger meals with water and extra sauce:

{
  "product_id": "burger-meal",
  "quantity": 2,
  "modifiers": [
    { "group_id": "size", "option_id": "large" },
    { "group_id": "drink", "option_id": "water" },
    { "group_id": "meal-extras", "option_id": "sauce" }
  ]
}

Use one line per distinct configuration. If the two meals have different drinks, send two lines with quantity 1. The combos and bundles guide explains how sizes, included items and extras are priced.

Calculate the total

Copy one of these functions into your backend. Each applies the same rules as the gateway: it matches the exact variant, enforces each group’s minimum and maximum, adds deposits and packaging, and adds the delivery fee once. It returns the total in bani, or raises an error with the code the gateway would return.

price-cart.mjs
// Price a cart against a published Nexus menu, applying the same checks as the gateway.
// Amounts are integer bani (3700 = 37.00 RON). Unavailable or invalid selections throw
// CartError with the conflict code the gateway would return.

export class CartError extends Error {
  constructor(code) {
    super(code);
    this.code = code;
  }
}

// Returns the order total in bani for `items` (the order's items array).
export function priceCart(menu, fulfillment, items, tip = 0, checkoutDiscount = null) {
  if (menu.accepting_orders === false) throw new CartError("restaurant_closed");
  if (!Number.isInteger(tip) || tip < 0 || tip > 100_000_000) throw new CartError("invalid_request");
  if (tip && !menu.tips_enabled) throw new CartError("tips_unavailable");
  const products = new Map(menu.products.map((product) => [product.id, product]));
  let total = tip + (fulfillment === "delivery" ? menu.delivery_fee ?? 0 : 0);
  for (const line of items) {
    total += unitPrice(products.get(line.product_id), line.modifiers ?? []) * line.quantity;
  }
  if (checkoutDiscount !== null) {
    const { amount, product_ids: scope } = checkoutDiscount;
    const ids = new Set(items.map(line => line.product_id));
    if (!Number.isInteger(amount) || amount < 1 || amount > 100_000_000 ||
        (scope != null && (!scope.length || new Set(scope).size !== scope.length || scope.some(id => !ids.has(id)))))
      throw new CartError("invalid_checkout_discount");
    const eligible = items.filter(line => scope == null || scope.includes(line.product_id))
      .reduce((sum, line) => sum + unitPrice(products.get(line.product_id), line.modifiers ?? [], true) * line.quantity, 0);
    if (amount > eligible) throw new CartError("invalid_checkout_discount");
    total -= amount;
  }
  return total;
}

const choiceKey = (groupId, optionId) => JSON.stringify([groupId, optionId]);

function unitPrice(product, modifiers, baseOnly = false) {
  if (!product || product.available === false) throw new CartError("product_unavailable");
  const selected = new Set(modifiers.map((choice) => choiceKey(choice.group_id, choice.option_id)));
  if (selected.size !== modifiers.length) throw new CartError("duplicate_modifier");
  const groups = product.modifier_groups ?? [];
  const known = new Set(groups.flatMap((group) => group.options.map((option) => choiceKey(group.id, option.id))));
  if ([...selected].some((choice) => !known.has(choice))) throw new CartError("unknown_modifier");

  // A product with variants is priced by the one variant matching all "variant" choices.
  let deposit = product.deposit ?? 0;
  let unit = product.price - (product.discount ?? 0);
  if (product.variants?.length) {
    const definingGroups = new Set(groups.filter((group) => group.kind === "variant").map((group) => group.id));
    const defining = modifiers.filter((choice) => definingGroups.has(choice.group_id))
      .map((choice) => choiceKey(choice.group_id, choice.option_id));
    const variant = product.variants.find((candidate) => candidate.selections.length === defining.length &&
      candidate.selections.every((s) => defining.includes(choiceKey(s.group_id, s.option_id))));
    if (!variant || variant.available === false) throw new CartError("variant_unavailable");
    deposit = variant.deposit ?? 0;
    unit = variant.price - (variant.discount ?? 0);
  }
  if (baseOnly) return unit;
  unit += deposit + (product.packaging ?? 0);

  // Every group enforces its bounds; selected options add their price and deposit.
  for (const group of groups) {
    const chosen = group.options.filter((option) => selected.has(choiceKey(group.id, option.id)));
    if (chosen.length < (group.minimum ?? 0) || chosen.length > (group.maximum ?? 1)) {
      throw new CartError("modifier_selection_invalid");
    }
    if (chosen.some((option) => option.available === false)) throw new CartError("modifier_unavailable");
    unit += chosen.reduce((sum, option) => sum + option.price + (option.deposit ?? 0), 0);
  }
  return unit;
}
price_cart.py
"""Price a cart against a published Nexus menu, applying the same checks as the gateway.

Amounts are integer bani (3700 = 37.00 RON). Unavailable or invalid selections raise
CartError with the conflict code the gateway would return.
"""


class CartError(Exception):
    def __init__(self, code):
        super().__init__(code)
        self.code = code


def price_cart(menu, fulfillment, items, tip=0, checkout_discount=None):
    """Return the order total in bani for `items` (the order's items array)."""
    if not menu.get("accepting_orders", True):
        raise CartError("restaurant_closed")
    if type(tip) is not int or not 0 <= tip <= 100_000_000:
        raise CartError("invalid_request")
    if tip and not menu.get("tips_enabled", False):
        raise CartError("tips_unavailable")
    products = {product["id"]: product for product in menu["products"]}
    total = tip + (menu.get("delivery_fee", 0) if fulfillment == "delivery" else 0)
    for line in items:
        total += unit_price(products.get(line["product_id"]), line.get("modifiers", [])) * line["quantity"]
    if checkout_discount is not None:
        scope = checkout_discount.get("product_ids")
        ids = {line["product_id"] for line in items}
        amount = checkout_discount.get("amount")
        if (type(amount) is not int or not 1 <= amount <= 100_000_000
                or scope is not None and (not scope or len(set(scope)) != len(scope) or not set(scope) <= ids)):
            raise CartError("invalid_checkout_discount")
        eligible = sum(unit_price(products[line["product_id"]], line.get("modifiers", []), True) * line["quantity"]
                       for line in items if scope is None or line["product_id"] in scope)
        if amount > eligible:
            raise CartError("invalid_checkout_discount")
        total -= amount
    return total


def unit_price(product, modifiers, base_only=False):
    if product is None or not product.get("available", True):
        raise CartError("product_unavailable")
    selected = {(choice["group_id"], choice["option_id"]) for choice in modifiers}
    if len(selected) != len(modifiers):
        raise CartError("duplicate_modifier")
    groups = product.get("modifier_groups", [])
    if not selected <= {(group["id"], option["id"]) for group in groups for option in group["options"]}:
        raise CartError("unknown_modifier")

    # A product with variants is priced by the one variant matching all "variant" choices.
    deposit = product.get("deposit", 0)
    unit = product["price"] - product.get("discount", 0)
    if product.get("variants"):
        defining_groups = {group["id"] for group in groups if group.get("kind", "extra") == "variant"}
        defining = {choice for choice in selected if choice[0] in defining_groups}
        variant = next((variant for variant in product["variants"]
                        if {(s["group_id"], s["option_id"]) for s in variant["selections"]} == defining), None)
        if variant is None or not variant.get("available", True):
            raise CartError("variant_unavailable")
        deposit = variant.get("deposit", 0)
        unit = variant["price"] - variant.get("discount", 0)
    if base_only:
        return unit
    unit += deposit + product.get("packaging", 0)

    # Every group enforces its bounds; selected options add their price and deposit.
    for group in groups:
        chosen = [option for option in group["options"] if (group["id"], option["id"]) in selected]
        if not group.get("minimum", 0) <= len(chosen) <= group.get("maximum", 1):
            raise CartError("modifier_selection_invalid")
        if any(not option.get("available", True) for option in chosen):
            raise CartError("modifier_unavailable")
        unit += sum(option["price"] + option.get("deposit", 0) for option in chosen)
    return unit
price-cart.php
<?php
// Price a cart against a published Nexus menu, applying the same checks as the gateway.
// Amounts are integer bani (3700 = 37.00 RON). Unavailable or invalid selections throw
// CartError whose message is the conflict code the gateway would return.
// Decode JSON with json_decode($json, true) so the menu and items are arrays.

final class CartError extends RuntimeException {}

/** Returns the order total in bani for $items (the order's items array). */
function price_cart(array $menu, string $fulfillment, array $items, mixed $tip = 0, ?array $checkoutDiscount = null): int
{
    if (!($menu['accepting_orders'] ?? true)) throw new CartError('restaurant_closed');
    if (!is_int($tip) || $tip < 0 || $tip > 100000000) throw new CartError('invalid_request');
    if ($tip && !($menu['tips_enabled'] ?? false)) throw new CartError('tips_unavailable');
    $products = array_column($menu['products'], null, 'id');
    $total = $tip + ($fulfillment === 'delivery' ? ($menu['delivery_fee'] ?? 0) : 0);
    foreach ($items as $line) {
        $total += unit_price($products[$line['product_id']] ?? null, $line['modifiers'] ?? []) * $line['quantity'];
    }
    if ($checkoutDiscount !== null) {
        $amount = $checkoutDiscount['amount'] ?? null;
        $scope = $checkoutDiscount['product_ids'] ?? null;
        $ids = array_column($items, 'product_id');
        if (!is_int($amount) || $amount < 1 || $amount > 100000000 ||
            ($scope !== null && (!$scope || count(array_unique($scope)) !== count($scope) || array_diff($scope, $ids))))
            throw new CartError('invalid_checkout_discount');
        $eligible = 0;
        foreach ($items as $line) {
            if ($scope === null || in_array($line['product_id'], $scope, true))
                $eligible += unit_price($products[$line['product_id']], $line['modifiers'] ?? [], true) * $line['quantity'];
        }
        if ($amount > $eligible) throw new CartError('invalid_checkout_discount');
        $total -= $amount;
    }
    return $total;
}

function choice_key(string $groupId, string $optionId): string
{
    return json_encode([$groupId, $optionId]);
}

function unit_price(?array $product, array $modifiers, bool $baseOnly = false): int
{
    if ($product === null || !($product['available'] ?? true)) throw new CartError('product_unavailable');
    $selected = [];
    foreach ($modifiers as $choice) $selected[choice_key($choice['group_id'], $choice['option_id'])] = true;
    if (count($selected) !== count($modifiers)) throw new CartError('duplicate_modifier');
    $groups = $product['modifier_groups'] ?? [];
    $known = [];
    foreach ($groups as $group) {
        foreach ($group['options'] as $option) $known[choice_key($group['id'], $option['id'])] = true;
    }
    if (array_diff_key($selected, $known)) throw new CartError('unknown_modifier');

    // A product with variants is priced by the one variant matching all "variant" choices.
    $deposit = $product['deposit'] ?? 0;
    $unit = $product['price'] - ($product['discount'] ?? 0);
    if (!empty($product['variants'])) {
        $definingGroups = [];
        foreach ($groups as $group) {
            if (($group['kind'] ?? 'extra') === 'variant') $definingGroups[$group['id']] = true;
        }
        $defining = [];
        foreach ($modifiers as $choice) {
            if (isset($definingGroups[$choice['group_id']])) $defining[choice_key($choice['group_id'], $choice['option_id'])] = true;
        }
        $variant = null;
        foreach ($product['variants'] as $candidate) {
            $selections = [];
            foreach ($candidate['selections'] as $s) $selections[choice_key($s['group_id'], $s['option_id'])] = true;
            if ($selections == $defining) { $variant = $candidate; break; }
        }
        if ($variant === null || !($variant['available'] ?? true)) throw new CartError('variant_unavailable');
        $deposit = $variant['deposit'] ?? 0;
        $unit = $variant['price'] - ($variant['discount'] ?? 0);
    }
    if ($baseOnly) return $unit;
    $unit += $deposit + ($product['packaging'] ?? 0);

    // Every group enforces its bounds; selected options add their price and deposit.
    foreach ($groups as $group) {
        $chosen = array_filter($group['options'], fn ($option) => isset($selected[choice_key($group['id'], $option['id'])]));
        if (count($chosen) < ($group['minimum'] ?? 0) || count($chosen) > ($group['maximum'] ?? 1)) {
            throw new CartError('modifier_selection_invalid');
        }
        foreach ($chosen as $option) {
            if (!($option['available'] ?? true)) throw new CartError('modifier_unavailable');
            $unit += $option['price'] + ($option['deposit'] ?? 0);
        }
    }
    return $unit;
}

Download: price-cart.mjs · price_cart.py · price-cart.php. The documentation tests compare these helpers with gateway pricing. For scheduled orders, pass the menu fetched for the chosen fulfillment_at; the helpers do not calculate time-zone or booking-window eligibility themselves. The gateway validates again on submission.

Use the result as the order’s total:

import { CartError, priceCart } from "./price-cart.mjs";

try {
  order.total = priceCart(menu, order.fulfillment, order.items, order.tip ?? 0, order.checkout_discount ?? null);
} catch (error) {
  if (!(error instanceof CartError)) throw error;
  // error.code is a conflict code such as "variant_unavailable".
}
from price_cart import CartError, price_cart

try:
    order["total"] = price_cart(menu, order["fulfillment"], order["items"], order.get("tip", 0), order.get("checkout_discount"))
except CartError as error:
    ...  # error.code is a conflict code such as "variant_unavailable".
<?php
require __DIR__ . '/price-cart.php';

try {
    $order['total'] = price_cart($menu, $order['fulfillment'], $order['items'], $order['tip'] ?? 0, $order['checkout_discount'] ?? null);
} catch (CartError $error) {
    // $error->getMessage() is a conflict code such as "variant_unavailable".
}

With the sample menu, the burger line above costs 3,350 bani per meal. Two meals with delivery come to 7,400 bani (74.00 RON).

Explain problems to the customer

CodeWhat to show
product_unavailableThis item is no longer available. Remove it from the cart.
variant_unavailableThis combination, such as size and crust, can’t be ordered. Ask the customer to choose again.
modifier_unavailableOne of the selected options is sold out. Ask for another choice.
modifier_selection_invalidA required choice is missing or too many options are selected. Highlight the group.
restaurant_closedThe restaurant isn’t taking orders right now. Disable checkout.
unknown_modifier, duplicate_modifierA bug in your cart code: the line doesn’t match the menu. Log it and rebuild the line.

When to recalculate

  • Every time the cart changes, so the customer always sees the real total.
  • After fetching the menu again before checkout. Prices only change with a new revision, but availability can change at any time.
  • After 409 menu_changed: fetch the menu, recalculate and ask the customer to confirm any price change.

Never recalculate an order you have already submitted. A retry must send the saved request unchanged; see the retry protocol.

Published promotions

Products and variants may include discount: an integer amount in bani per unit, defaulting to zero. Use price - discount + deposit + packaging + extras, then multiply by quantity. For variants, use the variant price, discount and deposit. Deposits, packaging, extras and delivery fees are not discounted. Website product promotions are currently unavailable in Retail; new menus publish zero product discounts. The schema retains the discount field: if a snapshot contains a discount, use that published amount when calculating its total. Do not send discounts or voucher amounts in order lines; the gateway rejects unknown fields and recalculates the total from its snapshot.

Restaurant staff tips

When menu.tips_enabled is true, the customer may choose a restaurant staff tip. Send tip as nonnegative integer bani, default zero, and include it once in total. The sample pricing functions accept it as an optional fourth argument: priceCart(menu, fulfillment, items, tip) or price_cart(menu, fulfillment, items, tip). The tip uses Retail’s configured POS tip article on the receipt. It is not discounted and can accompany any supported payment declaration. Never include courier tips or money retained by the website in this field.

Checkout discounts

Send one restaurant-funded checkout_discount with a coupon code and a positive amount in integer bani. Omit product_ids to discount all ordered products, or list unique eligible product IDs. Every listed product must occur in the order. The website backend checks coupon eligibility; Nexus validates the amount and fiscal allocation.

{ "code": "WELCOME10", "amount": 600, "product_ids": ["prod-101"] }

The eligible base is the selected product/variant price after published promotions, multiplied by quantity. Extra options, SGR deposits, packaging, delivery fees and staff tips are excluded. Published promotions and the coupon stack in that order. Amounts exceeding the eligible base are rejected with 409 invalid_checkout_discount. Percentage coupons must be converted by your backend to an exact bani amount.

Nexus distributes the amount proportionally across eligible order lines. Whole bani are allocated first; remaining bani go to the largest fractional remainders, tied by request line order. The sum is exact. For 60 lei of eligible products, a 6 lei coupon and 10 lei delivery, submit 64 lei. The coupon, scope and line order are part of the immutable request: preserve them on retries.

The downloadable pricing helpers accept the optional coupon after the tip argument. Refund reporting uses the discounted order total. Third-party-funded subsidies and payment updates are not supported.

Nexus GatewayWebsite Ordering API · v1.0.0

Type to search the integration guides.