Nexus Gateway
EnglishRomână
OpenAPI

Meniul listează ce poate alege clientul. Coșul transformă aceste alegeri în linii de comandă, iar backendul tău calculează totalul pe care gateway-ul îl verifică.

De la opțiuni la linii de comandă

Fiecare linie a coșului devine un articol: ID-ul produsului, cantitatea și fiecare opțiune selectată de client ca pereche group_id/option_id. Include toate opțiunile: mărimea, băutura inclusă și orice extra. Nu trimite prețuri, ID-uri de variante sau nume.

De exemplu, clientul comandă două meniuri burger mari, cu apă și sos suplimentar:

{
  "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" }
  ]
}

Folosește o linie pentru fiecare configurație distinctă. Dacă cele două meniuri au băuturi diferite, trimite două linii cu cantitatea 1. Ghidul pentru combo-uri și pachete explică prețurile mărimilor, componentelor incluse și extra.

Calculează totalul

Copiază una dintre aceste funcții în backend. Fiecare aplică aceleași reguli ca gateway-ul: identifică varianta exactă, respectă minimul și maximul fiecărui grup, adaugă garanțiile și ambalajele, apoi taxa de livrare o singură dată. Întoarce totalul în bani sau ridică o eroare cu același cod pe care l-ar întoarce gateway-ul.

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;
}

Descarcă: price-cart.mjs · price_cart.py · price-cart.php. Testele documentației compară aceste funcții cu prețurile gateway-ului. Pentru comenzi programate, transmite meniul citit pentru fulfillment_at ales; funcțiile nu calculează singure eligibilitatea fusului orar sau a intervalului de programare. Gateway-ul validează din nou la trimitere.

Folosește rezultatul drept total al comenzii:

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".
}

În meniul demonstrativ, linia de burger de mai sus costă 3.350 bani per meniu. Două meniuri cu livrare au un total de 7.400 bani (74,00 RON).

Explică problemele clientului

CodCe să afișezi
product_unavailableAcest produs nu mai este disponibil. Elimină-l din coș.
variant_unavailableAceastă combinație, de exemplu mărime și blat, nu poate fi comandată. Cere clientului să aleagă din nou.
modifier_unavailableUna dintre opțiunile selectate este epuizată. Cere o altă alegere.
modifier_selection_invalidLipsește o opțiune obligatorie sau sunt selectate prea multe opțiuni. Evidențiază grupul.
restaurant_closedRestaurantul nu primește comenzi momentan. Dezactivează finalizarea comenzii.
unknown_modifier, duplicate_modifierO eroare în codul coșului: linia nu corespunde meniului. Înregistrează problema și reconstruiește linia.

Când recalculezi

  • La fiecare modificare a coșului, pentru ca totalul afișat clientului să fie mereu corect.
  • După recitirea meniului înainte de finalizare. Prețurile se schimbă doar odată cu o revizie nouă, dar disponibilitatea se poate schimba oricând.
  • După 409 menu_changed: citește meniul, recalculează și cere clientului să confirme orice modificare de preț.

Nu recalcula niciodată o comandă deja trimisă. O reîncercare trebuie să trimită cererea salvată fără modificări; vezi protocolul de reîncercare.

Promoții publicate

Produsele și variantele pot include discount: o sumă întreagă în bani per unitate, implicit zero. Folosește price - discount + deposit + packaging + extras, apoi înmulțește cu cantitatea. Pentru variante, folosește prețul, reducerea și garanția variantei. Garanțiile, ambalajele, extra și taxele de livrare nu sunt reduse. Promoțiile pe produs pentru Website nu sunt disponibile în prezent în Retail; meniurile noi publică reduceri pe produs egale cu zero. Schema păstrează câmpul discount: dacă un instantaneu conține o reducere, folosește suma publicată la calcularea totalului. Nu trimite reduceri sau valori de vouchere în liniile comenzii; gateway-ul respinge câmpurile necunoscute și recalculează totalul din instantaneul salvat.

Bacșiș pentru personalul restaurantului

Când menu.tips_enabled este true, clientul poate alege un bacșiș pentru personalul restaurantului. Trimite tip ca număr întreg nenegativ de bani, implicit zero, și include-l o singură dată în total. Funcțiile exemplu acceptă acest parametru ca al patrulea argument opțional: priceCart(menu, fulfillment, items, tip) sau price_cart(menu, fulfillment, items, tip). Bacșișul folosește articolul configurat în POS pe bon. Nu este redus și poate însoți orice declarație de plată acceptată. Nu include bacșișuri pentru curier sau sume reținute de website în acest câmp.

Reduceri la finalizarea comenzii

Trimite un singur checkout_discount suportat de restaurant, cu un cod de cupon și o sumă pozitivă în bani întregi. Omite product_ids pentru toate produsele comandate sau specifică ID-uri eligibile unice. Fiecare produs indicat trebuie să existe în comandă. Backendul website-ului verifică eligibilitatea cuponului; Nexus validează suma și alocarea fiscală.

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

Baza eligibilă este prețul produsului/variantei selectate după promoțiile publicate, înmulțit cu cantitatea. Extraopțiunile, garanțiile SGR, ambalajele, livrarea și bacșișul sunt excluse. Promoțiile publicate și cuponul se cumulează în această ordine. Reducerea peste baza eligibilă este respinsă cu 409 invalid_checkout_discount. Backendul transformă cupoanele procentuale într-o sumă exactă în bani.

Nexus distribuie suma proporțional între liniile eligibile. Alocă întâi bani întregi, apoi restul către cele mai mari resturi fracționare, cu departajare după ordinea liniilor din cerere. Suma este exactă. Pentru produse eligibile de 60 lei, cupon de 6 lei și livrare de 10 lei, trimite 64 lei. Cuponul, produsele eligibile și ordinea liniilor fac parte din cererea imuabilă: păstrează-le la reîncercări.

Funcțiile de calcul descărcabile acceptă cuponul opțional după argumentul pentru bacșiș. Raportarea rambursărilor folosește totalul redus. Subvențiile suportate de terți și actualizările plății nu sunt disponibile.

Nexus GatewayAPI pentru comenzi website · v1.0.0

Tastează pentru a căuta în ghidurile de integrare.