AploDelivery Partner API — full website scope (v1 extension) — implementation spec
Status: scope approved by the owner on 2026-09-23. It covers every process the website needs: menu and stop-list with iiko as source of truth, points and delivery zones, AploCard loyalty, payment, and guest profile. The ViL website developer integrates it. The docs must be corporate-grade (§9). Builds on PARTNER_API_SPEC.md (already live): conventions, auth, errors, scoping, webhooks and outbox are unchanged unless stated. All new routes stay under /v1 because nobody has integrated yet. The version policy (§9) starts from this release.
0. Architecture
website backend ──HMAC──▶ AploDelivery ──┬─▶ iikoCloud (menu, prices, stop-lists, payment types, zones*, is_alive) — read, cached
├─▶ AploCard (HMAC POS API: calculate, hold/confirm/cancel, coupons, certificates, accrue)
└─▶ POS plugin (order create, statuses, cancel, streets) — existing claim protocol
- The site talks only to AploDelivery. The iikoCloud keys and the AploCard HMAC key live only in our env. The site never sees them.
- iikoCloud data is synced into our DB by a background worker and served from our DB. The site's reads never wait on iiko, and iiko rate limits are not multiplied by site traffic. Freshness targets: stop-list ≤ 60 s, menu/prices ≤ 10 min, payment types and zones ≤ 1 h, is_alive ≤ 60 s.
- Money is integer kopecks (
amountMinor) everywhere. Business dates and times useEurope/Moscow, and every timestamp is RFC 3339 with an offset. - \* Zones: the source depends on the live iiko probe (§3.3). The API contract is identical either way.
New Go packages (layering Handler → Service → Repository, as today): internal/iikocloud (client, v2 auth with v1 fallback, retry/backoff, 429 handling) · internal/catalog (sync worker + repo) · internal/loyalty (AploCard HMAC client only - typed errors, Fake for tests) · internal/checkout (pure pricing engine + signed calculationId token; DeliveryPricer interface, ZeroPricer stub for the parallel zones branch) · internal/loyaltysaga (background worker: outbox confirm/cancel/accrue, two reaper sweeps) · internal/ratelimit (in-memory per-key limiter). Migrations 006_catalog, 007_points (both already live). Phase 3 shipped as migration 009_loyalty (register version 9), not the originally planned 007_checkout_loyalty/008_guest_ratelimit: 008 is reserved for the parallel zones branch (phase 2), and guest rate limiting turned out to need no table (in-memory is sufficient for the current single-instance deployment - see §4 deviations). 009 adds delivery_loyalty_saga, delivery_loyalty_outbox, delivery_loyalty_audit, delivery_loyalty_org_mapping, and widens delivery_webhook_outbox.event_type's CHECK constraint for the 4 new webhook events.
1. Catalog
1.1 GET /v1/points/{pointId}/menu
Full sellable menu for the point, merged with the stop-list.
{"pointId":"3","revision":"2026-09-23T10:00:00+03:00#a1b2","currency":"RUB",
"categories":[{"id":"…","name":"Роллы","sort":10,"parentId":null}],
"items":[{"id":"<iiko productId>","externalId":"<site dish id if mapped>","categoryId":"…","name":"…","description":"…",
"price":{"amountMinor":42000,"currency":"RUB"},"weightGrams":230,"nutrition":{"kcal":…,"protein":…,"fat":…,"carbs":…,"per":"100g"},
"imageUrl":"https://…","available":true,"unavailableReason":null,"sizes":[],"modifierGroups":[],"tags":["new","spicy"]}]}
- Source (decided by the 2026-09-23 probe):
/api/1/nomenclatureper organization. No external menu is linked to our apiLogin keys (/api/2/menureturns[],by_id46626 returnsEXTERNAL_MENU_DATA_MISSED). An item is sold at a point when it is a DISH/GOODS, not deleted, and has a size insizePrices[]whose nestedprice.isIncludedInMenuis true and whosecurrentPriceis non-null; the product object itself carries noisIncludedInMenufield (verified live 2026-09-23 against Боброво). AcurrentPriceof exactly0is excluded by default because it is a promo giveaway, for example «Маки-ролл с авокадо | Акция»; setDELIVERY_CATALOG_ALLOW_ZERO_PRICE=trueto include zero-price items instead. Categories are the nomenclature product groups (hide the service groups; the list is in config). Probe numbers: Боброво 224 products, 102 sellable products with the corrected rule (corrected down from the uncorrected probe reading of 106 on the menu with a price); Амурская 110; about 175 per org carry KBJU. - If the owner later links external menu 46626 (or a dedicated site menu) to the apiLogin in iikoWeb, the sync switches to
/api/2/menu/by_idbehind the same contract. That gives curated categories, client descriptions and photos. available=false+unavailableReason:"stop_list"when the stop-list balance is ≤ 0.ETag= revision.If-None-Matchgives304. The site should poll at most once a minute or use themenu.updatedwebhook.- Images: nomenclature has no images (0 in the probe). Phase 1 takes photos from iikoWeb external-menu items (existing
IikoWebExtMenuClientknowledge, matched by productId). Otherwise, until the menu is linked,imageUrlis null and the site keeps its own photos keyed by iiko productId. iiko photo URLs change on re-upload, so we never cache bytes. - Item
idis the iiko productId. Orders may reference items by iiko id (new, preferred) or by the legacy siteexternalProductIdvia product mappings (existing). This retires the 61-unmapped-dishes problem: a dish the site shows is by construction a dish the POS knows. - A product whose group is hidden/deleted/a modifier group, or that has no
parentGroupat all, is excluded from the menu entirely (implementation review HIGH-1) — there is no synthetic "Прочее"/uncategorized bucket, so every item on the menu always has a real, navigable category. weightGramsisiiko nomenclature weight × 1000(nomenclature weight is in kilograms), verified live 2026-09-23 against Боброво.
1.2 GET /v1/points/{pointId}/stop-list
Light, frequent read: {"pointId","revision","items":[{"id","balance"}]}. Webhook stoplist.updated.
1.3 GET /v1/catalog/revision
{"points":{"3":{"menu":"…","stopList":"…"}}} gives a cheap change detection for all points.
2. Points
2.1 GET /v1/points (extended, backward compatible)
Adds name, address, location{lat,lon}, phone, timezone:"Europe/Moscow", schedule[{weekday,open,close}], isOpenNow, acceptsOrders (= route active ∧ terminal alive ∧ within schedule), deliveryTypes:["courier","pickup"]. Point metadata that iiko lacks (display name, phone, schedule) lives in our delivery_points table, managed by operators (§8), seeded from the site's GET /api/rests data once.
2.2 GET /v1/points/{pointId}/status
{"acceptsOrders","terminalAlive","isOpenNow","reason"}. Webhook point.status_changed.
3. Delivery zones and address
3.1 POST /v1/delivery/resolve
Input {"location":{"lat","lon"}} and/or {"address":{"city","street","house"}} plus deliveryType. Output: {"serviceable":true,"pointId":"3","zoneId":"…","minOrder":{…},"deliveryFee":{…},"freeDeliveryFrom":{…},"etaMinutes":{"min":45,"max":60},"street":{"status":"ok|ambiguous|not_found|unknown","name":…}}. Point selection: the zone that contains the point wins. On overlap, lowest priority, then nearest point. A street check (existing §1.4 logic) runs against the chosen point's POS dictionary. serviceable=false + reason when outside all zones or the point is not accepting orders.
3.2 GET /v1/points/{pointId}/delivery-zones
{"zones":[{"id","name","polygon":[[lat,lon],…],"minOrder","deliveryFee","freeDeliveryFrom","etaMinutes","priority","active"}]}.
3.3 Zone source — decided: ours (B)
The 2026-09-23 probe returned deliveryZones: [] and restrictions: [] for all 26 ViL orgs, defaultMinSum=0 and no delivery-fee product. The only hint was a Google My Maps link (deliveryRegionsMapUrl). So zones live in our delivery_zones table. They are imported once from the site admin export (the site developer provides JSON/CSV) or from the Google My Maps KML, then managed in our operator admin (§8). The site stops using its own zones. iiko defaults are used as fallback ETA: courier 45 min, pickup 90 min. The contract (§3.1–3.2) is identical in both cases.
4. Checkout pricing
4.1 POST /v1/checkout/calculate
No side effects. Input:
{"pointId":"3","deliveryType":"courier","location":{…},"items":[{"id":"<iiko productId>","quantity":"2"}],
"guest":{"phone":"+7…"},"coupon":"ABC123","bonusToSpend":{"amountMinor":10000,"currency":"RUB"},"deliverAt":"…"}
Output: line prices from our catalog (never from the request), subtotal, programDiscount (automatic AploCard loyalty-program discounts), coupon{status, discount}, bonus{requested, maxSpend, spend}, deliveryFee, minOrder{met, missing}, total, warnings[] (e.g. item in stop-list, point closed), and calculationId (a signed token over the inputs and result, valid 15 min). Shipped coupon.status values: not_requested | applied | invalid - the engine's /calculate preview does not distinguish "invalid" from "expired" (no separate signal in its response), so both collapse to invalid here; a distinct expired status would need a dedicated engine field this phase does not have. Delivery is priced as a line after loyalty discounts. The existing gotcha is that promotions discount the delivery line. Decide per AploCard rule: the delivery line is excluded from discount bases (documented). Delivery-fee-line follow-up (owner decision 2026-09-23), shipped: pricing this fee into total was never the gap - deliveryFee/total above already reflected it. The gap was that the DeliveryCommand sent to the POS at order creation carried no line for it at all, so the created iiko order's own total fell short by the fee and the plugin-side POS invariant re-check could never balance. Closed with an additive deliveryFeeLine{iikoProductId, priceMinor} on the wire (domain.CommandDeliveryFeeLine, plugin/WIRE_CONTRACT.md's new section) - iikoProductId from DELIVERY_FEE_PRODUCT_ID (tenant-wide env, ViL's live «Доставка Приложение» Service product acdca4b3-8329-42ce-b8d2-5ab335a63b3d by default), priceMinor the zone fee actually priced. Absent for pickup and for a zero fee. internal/service.verifyMappedLoyaltyInvariant now takes the persisted payload.DeliveryFeeMinor as an explicit parameter rather than reading checkout.Result.DeliveryFeeMinor implicitly, so the check verifies what will actually reach the plugin, not merely what was priced a moment earlier.
4.2 Order creation with checkout (POST /v1/orders, extended)
New optional fields: calculationId, coupon, bonusToSpend, payment, deliveryType, location, comment, persons, callBack:false.
- The server recalculates. If the total differs from
calculationId(price changed, stop-list), it returns409 price_changedwith the fresh calculation. The site shows it and asks again. payment:{"method":"online|cash|card_on_delivery","paid":true,"externalPaymentId":"yookassa-…","amountMinor":…,"change":{"from":…}}. Online orders must be created after successful payment (paid:true). The plugin adds an external prepayment of type ECARD, matched by code, never by name. The probe found ECRDL deleted in iiko, so ECARD covers both courier and pickup. Order types: «Доставка курьером»76067ea3-…, «Самовывоз Сайт»c401fa52-….cashandcard_on_deliverymap to the POS payment types configured per point.- Loyalty saga (all idempotent by
sourceRefId = order id):- At ingest: coupon
hold+ bonusholdin AploCard. If either fails, the order is rejected422 loyalty_rejectedwith the detail. Nothing is created. created_verifiedgivesconfirmfor both.rejected_final/cancelledgivescancelfor both. Amanual_reviewstatus leaves the holds held and opens an alert.- Expiry we enforce (owner decision 2026-09-23, see §4.3): any hold whose order is not
created_verifiedwithin 30 min is acted on by our reaper, but the action depends on the order's own state - a hold is never released while the order could still reachcreated_verified.queued(never claimed) is cancelled outright, which releases the hold as part of that same action.claimed/prepared/create_sending/reconciliation_requiredonly getscancel_state=requested; the hold stays held until the order genuinely, definitively terminates (rejected_final/cancelled), at which point the normal step-3cancelfires. An order/saga row with no order at all, or one alreadyrejected_final/cancelledwhose own release attempt is stuck, is released directly. - Delivery status
Delivered/Closedgivesaccrue(rule-based) with status read-back on unknown outcome.
- At ingest: coupon
4.3 Implementation notes and deviations (phase 3, shipped 2026-09-23)
Verified against the live engine source (not assumed) and worth recording here so the next person does not re-derive them:
organizationIdin every AploCard POS-API call is AploCard's own internalorganizations.id, NOT the iiko organization id used everywhere else in this service (Route.OrganizationID). The engine resolves an iiko org id from it only for guest-lookup calls this service never makes. This is a materially different fact than §0's original assumption ("per-point org mapping = the route's iiko organizationId") - closed with a new operator-managed table,delivery_loyalty_org_mapping(tenant, source, external_point_id) → AploCard organization id, migration 009. A point with no row behaves exactly like AploCard being unconfigured: pricing works, coupon/bonus/accrual respond503 loyalty_unavailable.- Hold is synchronous at ingest, bounded by a 5s per-call timeout, and reservation happens before any AploCard call:
ReserveLoyaltySagainserts adelivery_loyalty_sagarow (stateholding) in its own small transaction, keyed by the deterministic order id, before the coupon/bonus hold calls. This row deliberately has no FK todelivery_orders(a failed hold must leave no order behind, so the order row may not exist yet when the saga row is written). A retried request for the sameexternalOrderId/order id sees the existing row and resumes instead of re-holding:heldlegs are reused as-is,failedlegs are retried, aholdingleg (mid-flight or crashed) is treated as in-progress and the caller is told to retry shortly. A leg stuckholdingfor more than ~1 minute (well above the 5s call timeout) is cleaned up by a dedicated reaper sweep independent of the spec's own 30-minute hold-not-verified reaper - seeinternal/loyaltysaga.Worker.resolveStuckHold. This narrows, but does not eliminate, the crash window between "AploCard call succeeded" and "we recorded it" - accepted risk, documented in code. - Resuming a retry must use the ALREADY-HELD amounts, not a fresh recalculation. A fresh
/calculateafter a successful hold reflects the wallet balance the hold already reduced (a lower cap) or a coupon that may no longer preview as applicable - recalculating naively would permanently mismatch thecalculationIdtoken signed on the first attempt.checkout.Input.HeldCouponDiscountMinor/HeldBonusSpendMinoroverride the fresh derivation with the saga's recorded amounts;internal/service.IngressCheckoutreads the saga row first for exactly this reason. payload.Items[i].Priceandpayment.amountMinorare independently re-verified against the fresh catalog/total, not just the signed token. The token binds catalog prices at calculate time; a partner could otherwise send an internally-consistent cart (own total matches own per-line prices, so the existingValidate()invariant passes) with a forged per-line price and a still-valid token. Any mismatch is409 price_changed.- Checkout item ids are the iiko productId directly (as this section already specifies) -
BuildDeliveryCommandnow falls back to parsing the item id as a UUID when no explicit product-mapping row exists, gated on the order actually being a checkout order (payload.Payment != nil). The legacy call_center/mobile_app path is unaffected and still requires an explicit mapping row. payment.codeand the order'sappliedDiscountsare server-authoritative even though they round-trip through the same JSON struct a partner could set them on. The checkout service unconditionally overwrites both right after decode/hold, before hashing/persisting - this is the guest-money trust boundary, and it is documented loudly at the struct definitions (domain.Payment,domain.AppliedDiscounts) so a future change does not casually skip the overwrite.payment.amountMinorfor cash/card_on_delivery gets a POS payment-type code fromDELIVERY_PAYMENT_CODE_CASH/DELIVERY_PAYMENT_CODE_CARD_ON_DELIVERY(tenant-wide env, not per-point). The spec's "configurable codes… per point" is narrower in this phase - a real per-point config surface belongs in §8's operator admin, which is out of scope for phase 3. Online always resolves toECARDper the phase-0 probe finding (ECRDL deleted).accrue'sitems[].amountis an int at AploCard; checkout quantities are decimal strings. Rounded to the nearest integer, minimum 1 for any positive quantity (internal/loyaltysaga.roundQuantity, mirrored in the ingest-time loyalty/calculatepreview byinternal/service.checkoutItemAmount). Not expected to matter in practice - dishes are sold as whole units - but flagged as a real, if narrow, rounding deviation.sourceRefId = order idis reused unmodified for hold and accrue despite them being different AploCard resources (wallet-transaction hold vs. accrual): verified the engine's idempotency key forAccrueis scoped to(organization, source, source_ref_id, tx_type='accrual'), so it cannot collide with a hold row (tx_typepresumablyhold/debit). No namespacing needed.- RESOLVED 2026-09-23 (owner decision, was the open "slow order" leak above): the reaper no longer releases a hold on a timer alone.
repository.FindExpiredLoyaltyHoldsclassifies each held-past-deadline leg by the order's actual state (ExpiredLoyaltyHoldKind:orphaned/terminal_failed/queued/in_flight) instead of the old blanket "not created_verified/manual_review ⇒ release" predicate.internal/loyaltysaga.Worker.resolveExpiredHoldacts on each:orphaned/terminal_failedrelease immediately viaReleaseExpiredLoyaltyHold(nothing can consume them - no order, or the order's own release already tried and is stuck);queuedcalls the SAMErepository.Cancelthe partner-facing§2.1endpoint uses, which cancels the order outright and releases the hold in that one transaction (safe - no plugin ever claimed it);in_flight(claimed/prepared/create_sending/reconciliation_required) ALSO callsrepository.Cancel, but that method's own logic for those states only setscancel_state=requestedand does not touch the loyalty outbox - the hold stayshelduntil the order genuinely reachesrejected_final/cancelled(releasing it then, via the pre-existing step-3enqueueLoyaltyOutboxon that transition) orcreated_verified(confirming it via step 2, discount intact). Either way a Михалыч alert fires (DELIVERY_MIHALYCH_TOKEN_FILE,internal/mihalych) so a human sees the stuck order, not just a partner-facing webhook. This closes option (b) from the original three, refined: cancellation is requested immediately (an honest signal) but the money-losing release is deferred to the actual terminal transition, not driven by the reaper itself. verifyOrderablealso gates the crash-resume path, on purpose - worth knowing before someone "fixes" it. If an item enters the stop-list between the original hold and a retry, the retry correctly 409s (spec: never let a stop-listed line reach the POS) - but the hold staysheldwith no order until either the guest gives up or the 30-minute reaper releases it. This is the intended trade-off (safety over availability) but is easy to mistake for a bug when someone notices aheldsaga row with no matching order for up to 30 minutes.- Open question for the owner: AploCard's write routes (hold, coupon/hold, confirm, cancel, coupon/confirm, coupon/cancel) are rate-limited 5 req/min per key. A coupon+bonus order is 2 holds + up to 2 confirms = 4 writes against that budget, meaning the whole ViL site is effectively capped at roughly one loyalty-bearing order per minute on a single API key before partners see
429/hold_in_progressretries pile up. Not a phase-3 code fix - either AploCard needs a higher per-key limit for this integration, or the site needs multiple keys/some other mitigation. Flagging this before it is discovered in production. DeliveryStatusCancelledaftercreated_verified(a guest-cancelled order the POS already created and whose loyalty was already confirmed) does not release the confirmed coupon/bonus. This matches the spec's own wording literally (onlycreated_verified/rejected_final/cancelledcreation-states drive the saga; delivery-status-level cancellation is a separate, unaddressed case) but is worth an explicit decision from the owner: is a post-confirmation delivery cancellation supposed to also reverse the loyalty spend (arefund-style flow), or is that intentionally a manual/support-driven process?- Guest-phone rate limiting (spec §7, 30 calls/min) is in-memory, per service instance. Exact on a single instance (today's deployment shape); on N horizontally-scaled instances the effective ceiling becomes
30×N. A Redis-backed limiter is the natural upgrade if/when this service scales out - not built now to avoid a new external dependency for a phase that does not need it yet. accrue_state'snot_applicablevalue exists in migration 009's CHECK constraint but nothing in this phase sets it. Every checkout order that reaches AploCard tracking gets a saga row and eventuallypending → queued → accrued|failed;not_applicableis reserved for a future distinction (e.g. an order type the accrual rule set explicitly excludes) that phase 3 does not need to make.StateManualReviewholds are permanent with no automated release path, by design (the reaper explicitly excludesmanual_revieworders, matching "amanual_reviewstatus leaves the holds held and opens an alert" above) - but there is currently no alert beyond a structured log line from the reaper sweep, and §8's operator admin (where an actual hold-release action would live) is out of scope for this phase. An operator with amanual_revieworder today has no UI path to release its hold; treat this as a required follow-up, not a hidden gap.- Checkout order items do not carry modifiers.
internal/service.toCheckoutItemsmapsdomain.Itemtocheckout.RequestItem{ID, Quantity}only -Item.Modifiersis silently dropped from pricing/verification even thoughCanonicalOrder.Validate()still requiresTotalto account for modifier prices the partner sent. A checkout order with modifiers will failverifyNoTampering's line-price check (or pass by accident if the modifier total happens to be zero) rather than pricing the modifier from our catalog. Checkout does not support modifiers yet - flag this to partners/product before anyone tries to sell a modified dish through the checkout flow. - A money-leak edge case (hold succeeds, order Create fails, the 30-minute reaper releases the hold, the partner retries) is closed, not merely documented as accepted risk:
internal/service.IngressCheckoutonly counts a leg's discount towardappliedDiscountswhen that leg isheldorconfirmed(nevercancelled/failed), andattemptHoldonly restarts acancelledleg when the caller has independently confirmed viaGetByIDthat the order does not exist yet - restarting one for an order that already exists would orphan a second, uncorrelated hold. Both branches (safe restart; refused restart when the order exists, which then surfaces as an ordinary idempotency conflict) have tests. - A second gap closed the same review pass:
checkout.Calculateonly warns about an unknown/stop-listed line or an unmet minimum/unserviceable delivery - it does not refuse to price them.internal/service.verifyOrderableis a hard gate inIngressCheckout, run right after the calculationId itself verifies: any line with a non-emptyWarning, or!MinOrder.Met, or!DeliveryServiceable, rejects with the same409 price_changeda genuine price mismatch would - the token can be entirely honest (nothing changed since calculate) and the order must still not reach the POS with a stop-listed line at full price or an unknown item at price 0 via the checkout product-id passthrough. - Loyalty discount/gift-to-POS mapping (phase 3 follow-up, owner decision 2026-09-23), shipped: "Скидку на заказ надо привязывать к скидке в айке по ID - блюда в подарок - к блюду с 0-й ценой из айки. Акционное блюдо - к акционному блюду." A new operator-managed table,
delivery_loyalty_discount_mapping(migration 010, kindsorder_discount/gift_product/promo_product), translates AploCard's calculate-time result to iiko entities: everydiscounts[]entry (checkout.Calculate already counts each one in exactly one ofProgramDiscountMinor/Coupon.DiscountMinorregardless of whether it is coupon- or automatic-program-driven -loyaltyProgramResults/ProgramResultsturned out to be unused by pricing entirely) maps bycodeto an iikodiscountTypeId; everyfreeProducts[]entry maps byproductIdunder either the gift or promo kind (the engine contract does not distinguish them at this field - open question below). An unmapped entry, or a mapped sum that does not equal whatcheckout.Calculatealready verified againstpayment.amountMinor, rejects the order outright (422 loyalty_unmapped) before any AploCard hold is attempted - never a silently dropped or invented discount.catalog.BuildMenu/CatalogItem.Hiddennow always stores a zero-price catalog item (instead of dropping it, phase-1's behavior) so it can be agift_product/promo_productmapping target while staying off the partner-facing menu.domain.MappedDiscount/GiftLineonCanonicalOrderanddomain.CommandDiscount/CommandGiftLineonDeliveryCommandcarry the mapped result to the plugin (plugin/WIRE_CONTRACT.md's newdiscounts/giftLinessection - Front APIIOperationService.GetDiscountTypeById+IEditSession.AddFlexibleSumDiscountfor discounts,AddOrderProductItematpredefinedPrice:0for gift lines; plugin implementation is a separate, not-yet-started change). Open question for the owner, not a phase-3 code decision: the AploCard/calculatecontract has no field distinguishing "gift" from "promo" - both arrive asfreeProducts[].productIdwith no semantic tag, so this implementation triesgift_productthenpromo_productfor the same key and lets the operator record which is which purely for their own bookkeeping. If AploCard is ever meant to actually differentiate them (e.g. different receipt text, different reporting), the engine contract needs a new field first. - Hidden item ordered directly, closed (advisor review, round-2): a reviewer flagged - unverified - that a
Hidden(zero-price gift/promo) catalog item could in principle be ordered as an ordinary cart line via the checkout product-id passthrough (BuildDeliveryCommand's UUID fallback), double-counting against a mappedgiftLines[]entry for the same physical item on the same order. Checked and confirmed possible before this fix (the item WAS present, just priced 0, in the unfiltered catalogbuildCheckoutInputreads). Closed with a dedicatedcheckout.Calculatewarning ("hidden_item", distinct from"stop_list") andinternal/service.verifyOrderablerejecting it as422 product_not_orderablebefore any hold/order-creation - never the same 409 a genuinely stop-listed item gets, since this is categorical, not transient. - Bonus spend never reached the POS, critical gap closed (round-2 review, owner decision 2026-09-23):
mapLoyaltyResult/verifyMappedLoyaltyInvariantoriginally covered only program+coupon discounts - bonus spend was subtracted frompayment.amountMinor(so the signed total was correct) but was never told to the POS as anything, so the cheque itself never balanced on ANY bonus-spending order (cart 100000, bonus 20000 ⇒payment.amountMinor80000, POS order total unchanged at 100000). Owner's decision, verbatim: "Там уже есть вкус коины, мы используем ту же самую валюту — вкус коины" - ViL's live iiko already has an external/processedVKUS2 | Вкускоин | kind Card | processing Bothpayment type (neverVKUS3, which iskind IikoCard | processing Internaland cannot be added externally). Bonus spend now rides to the POS as a SECOND payment entry of codeDELIVERY_PAYMENT_CODE_BONUS(defaultVKUS2), additive alongside the existing singularpaymentfield via the newdomain.CommandPaymentEntry/DeliveryCommand.Payments(plugin/WIRE_CONTRACT.md's newpayments[]section).verifyMappedLoyaltyInvariantwas extended from "mapped-discounts-sum matches the priced discount" to the full server-side invariant the owner specified:Σ(line prices at catalog price) + gift lines(0) - Σ(discounts[].sumMinor) + delivery fee == Σ(payments[].amountMinor), checked after the hold attempt (once the actually-held bonus amount is known) rather than at the earlier fail-fast unmapped-discount check. - The Postgres loyalty repository path (migration 009 tables) has no coverage against a real database anywhere in this branch. One pgxmock test (
TestReserveLoyaltySagaScansNullCouponCode) exercisesscanLoyaltySaga/ReserveLoyaltySagaagainst a mocked row shaped like a no-coupon order - it caught and pins the fix for a real bug (the nullablecoupon_codecolumn was scanned into a non-pointerstring, which pgx v5 refuses on NULL) - but pgxmock does not reproduce pgx's actual NULL-into-non-pointer error (verified: reverting the fix locally still passes that test against pgxmock), so it is not proof against a live database, only a plausibility check. Every other Postgres method added in this phase (UpdateLoyaltyLeg,DueLoyaltyOutbox,FindExpiredLoyaltyHolds/ReleaseExpiredLoyaltyHold,SetLoyaltyAccrueResult,RecordLoyaltyAudit,EnqueueOrderWebhook, theenqueueLoyaltyOutbox/enqueueLoyaltyAccruetransaction hooks) is untested beyond compiling and passinggo vet. Running the full checkout+saga flow against a real (even sandbox) Postgres instance before this ships is a hard prerequisite, not a nice-to-have.- UPDATE 2026-09-23 (mid-DS review round): this is now partially addressed -
postgres_integration_test.go(build tagpgintegration) runs against a copy of the production database and exercisesCreate,Cancel,AppendEvent's loyalty-outbox hooks,FindStuckLoyaltyHolds,FindExpiredLoyaltyHolds/ReleaseExpiredLoyaltyHold,RecordLoyaltyAudit, org-mapping lookup, concurrent-create races,RestartLoyaltyHold,SetLoyaltyAccrueResult,RecordDeliveryStatus, and outbox leasing. Still not covered live: thequeued/in_flightbranches ofFindExpiredLoyaltyHolds' classification (proved only againstrepository.Memory, which mirrors the sameCASEthe Postgres query uses - kept off the integration suite deliberately, see the comment onTestPostgresIntegrationCancelWritesLoyaltyOutboxAndWebhookEvent, to avoid growing the number of permanentdelivery_ordersrows left in the shared database copy) and a live AploCard sandbox call of any kind.
- UPDATE 2026-09-23 (mid-DS review round): this is now partially addressed -
- The idempotent-retry-vs-confirmed-legs gap (advisor mid-DS review M1, fixed 2026-09-23): a retry of
POST /v1/ordersfor anexternalOrderIdwhose order already reachedcreated_verifiedused to re-derive a fresh price and fail with a spurious409 price_changed.internal/service.IngressCheckoutonly reusedheldamounts (checkout_ingress.go's crash-resume block), neverconfirmedones - but a leg only ever becomesconfirmedonce the order already exists (loyaltysaga.Worker.confirm, driven off thecreated_verifiedtransition), so recalculating for a retry at that point compared a stale signed total against numbers AploCard could no longer reproduce (the discount was already spent). Fixed by short-circuiting before any recalculation: if the order already exists,Service.replayExistingOrdercopiesPayment.Code/AppliedDiscountsstraight off the STORED order (immutable once created) rather than re-deriving them from the current saga (an earlier version of the fix did that, and returnedErrPayloadConflicton an ordinary retry if a leg happened to drift, e.g.confirmed→released by an unrelated later cancellation) - then defers torepository.Create's existing idempotent-replay-by-hash logic on the rest of the payload. A genuine conflict (sameexternalOrderId, materially different order content elsewhere) still surfacesErrPayloadConflictunchanged. - The delivery total was priced correctly but never sent to the POS as a line, closed (delivery-fee-line follow-up, owner decision 2026-09-23).
checkout.Result.DeliveryFeeMinor/payload.Payment.AmountMinoralways included the zone fee - the price the guest saw and paid was always right - but nothing indomain.BuildDeliveryCommand's output told the plugin to charge for delivery at the POS, so a fee-bearing order's created iiko order total fell short by exactly the fee.domain.CanonicalOrdergainedDeliveryFeeMinor/DeliveryFeeProductID(server-authoritative, same overwrite-after-decode trust boundary asPayment.Code, set once inIngressCheckoutand replayed unchanged on retry -internal/repositorybuilds the wire command from the STORED order at claim time, long after the request's config is gone), andDeliveryCommandgained an additiveDeliveryFeeLine(plugin/WIRE_CONTRACT.md's newdeliveryFeeLinesection) populated only when the fee is non-zero andDELIVERY_FEE_PRODUCT_IDis configured - "fee 0 ⇒ no line, pickup ⇒ no line" naturally follow, since a pickup order'sDeliveryPricerquote is always 0.verifyMappedLoyaltyInvariantnow takes the delivery-fee amount as an explicit parameter (the exact value that becomes the wire line) rather than reading it implicitly offcheckout.Result.
5. Guest
GET /v1/guests/{phone}/loyalty: balances per program, level/category, and active coupons. This uses AploCard via our server key.GET /v1/guests/{phone}/orders?limit&cursor: orders of this partner's source for this phone, newest first, with statuses (from our DB).GET /v1/guests/{phone}/addresses: last used delivery addresses (from our orders).- Phone normalisation: E.164
+7XXXXXXXXXX. Invalid gives400 invalid_phone. - Trust model (must be in the docs, bold): the partner backend is trusted to assert the guest's phone. It must authenticate the guest itself (SMS/login) before calling guest or loyalty endpoints. Every guest/loyalty call is written to an audit log (
partner, phone hash, route, result, no plain phone). Separate rate limit per phone (§7). - PROPOSED, owner sign-off pending (phase 3 follow-up, 2026-09-23): this same trust model applies to §4's checkout endpoints, not only the not-yet-deployed §5 ones above -
POST /v1/checkout/calculate/POST /v1/ordersuseguest.phonedirectly against AploCard (balance/coupon/hold) BEFORE any order exists, exactly the same "we trust the caller's phone" exposure this bullet already documents forGET /v1/guests/.... It was never a separate explicit decision when checkout shipped; extending the existing rule here is the logical continuation, but is recorded as proposed, not settled, until the owner confirms. Seedocs/portal/policies/security.md's guest-trust-model section and the newdocs/portal/guides/checkout.mdstub (same "proposed" marking, kept in sync).
6. Webhooks (extended)
New event types: menu.updated, stoplist.updated, point.status_changed, order.payment_recorded, order.loyalty_confirmed, order.loyalty_released, order.accrued. Same signing, outbox, retries and dedupe as today. Subscriptions are per event type in the credential config (webhookEvents, default all).
7. Operability
- Rate limits (fixes the known gap): per credential 600 req/min; per IP 100 req/min for unauthenticated/failed-auth traffic; per guest phone 30 loyalty calls/min. The response is
429+Retry-After+X-RateLimit-Limit/Remaining/Reset. X-Request-IDis echoed on every response and logged. A support request quotes it.GET /v1/healthstays public.GET /v1/status(auth) reports sync freshness per source.- Idempotency:
POST /v1/ordersbyexternalOrderId, cancel by order. Every other POST is read-only or idempotent by body hash. - Structured JSON logs without PII. Metrics: request rate/latency/error by route, sync lag per source, loyalty saga states, webhook backlog.
8. Operator/admin (minimal, AploKey SSO, roles)
Point metadata (name, phone, schedule), zones (source B), payment-type mapping per point, partner credentials (issue/rotate API key and webhook secret, event subscriptions, point allowlist). The admin UI is a later phase. The first cut is an admin JSON API + CLI. authz → Sonnet gate.
9. Corporate-grade documentation (deliverable, same priority as code)
Published developer portal served by the service at /docs (static, versioned):
- API reference: OpenAPI 3.1 (single source of truth, lint-clean with Spectral), rendered with Redoc. Every field has a description, example, units and enum meanings.
- Guides (RU): Quick start (first signed call in 10 min); Authentication and signing (with test vectors); Checkout flow end-to-end (sequence diagram); Menu and stop-list sync strategy; Delivery zones and address resolution; Loyalty (coupons, bonuses, trust model); Payments (online before creation, cash, change); Order lifecycle and statuses (state diagram, customer-facing RU labels); Cancellation rules; Webhooks (verification, retries, dedupe); Errors catalogue (every code, cause, action); Rate limits; Idempotency; Go-live checklist.
- Policies: versioning and deprecation (additive changes any time; breaking changes only in a new major, with ≥ 90 days notice), changelog (dated, per release), support and escalation contacts, SLA/SLO (availability target, sync freshness), security (key storage, rotation, IP allowlist option, PII).
- Sandbox: separate tenant and keys on staging, with a POS simulator that walks orders through statuses (configurable delays, failure scenarios: stop-list, price change, cancel rejected), a fake point with a menu, and zones.
- SDK and examples: Go SDK (
pkg/ingressclient, all endpoints + webhook verification), a Postman collection, curl examples for every endpoint, and a signature test-vector file. - Docs are tested: examples in the guide are executed in CI against the sandbox (doc-tests), and the OpenAPI is validated against real handler responses (contract tests).
10. Phases and acceptance
| Phase | Content | Done when |
|---|---|---|
| 0 | Live iiko probe (read-only) — done 2026-09-23 (/home/dev/aplodelivery-rollout/iiko-probe-2026-09-23.md) | zones = ours; menu = nomenclature; ECARD only; v1 token works |
| 1 | iikocloud client + catalog sync + menu/stop-list/points/status/revision | menu for Боброво matches iiko; stop-list freshness ≤ 60 s measured; contract tests green |
| 2 | Zones + resolve (A or B) | resolve returns the right point for 20 real addresses of 5 points |
| 3 | Checkout calculate + order extension + loyalty saga + payment on POS (plugin) + reaper | Server side done 2026-09-23 (branch feat/aplodelivery-v2-checkout): unit/integration tests cover calculate pricing, tampered-item-price rejection, expired/tampered-token rejection, online-without-paid rejection, full coupon+bonus saga happy path, hold-failure compensation, crash-resume (retry reuses held amounts, zero duplicate AploCard calls), per-leg-idempotent confirm/cancel retry, both reaper sweeps, and accrue's transport-error read-back. Still open: an actual ECARD prepayment on a real POS order (needs the plugin build with the wire-contract additions in a companion change, and a live/sandbox AploCard tenant - neither exists in this branch's scope) and a genuine end-to-end sandbox run once the plugin side lands. |
| 4 | Guest endpoints + audit + rate limits + webhooks v2 | authz tests (cross-partner, cross-source, phone scoping) |
| 5 | Sandbox simulator + developer portal + SDK + Postman + doc-tests | a developer can go from zero to a sandbox order using only the portal |
Security gate: credentials, scoping, guest/loyalty endpoints, audit and rate limiting are written and reviewed by Sonnet (web-developer). Everything else follows the peer-review chain.