diff --git a/Cargo.lock b/Cargo.lock
index 89b6c53..da137b7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1504,9 +1504,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
+ "wasm-bindgen",
]
[[package]]
@@ -1755,6 +1757,22 @@ dependencies = [
"want",
]
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots 1.0.7",
+]
+
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -2110,6 +2128,7 @@ dependencies = [
"loco-rs",
"migration",
"regex",
+ "reqwest",
"rstest",
"sea-orm",
"serde",
@@ -2332,6 +2351,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
[[package]]
name = "mac"
version = "0.1.1"
@@ -3090,6 +3115,61 @@ dependencies = [
"serde",
]
+[[package]]
+name = "quinn"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2 0.5.10",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
+dependencies = [
+ "bytes",
+ "getrandom 0.3.4",
+ "lru-slab",
+ "rand 0.9.4",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2 0.5.10",
+ "tracing",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "quote"
version = "1.0.45"
@@ -3297,16 +3377,21 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
+ "hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
+ "tokio-rustls",
"tokio-util",
"tower 0.5.3",
"tower-http",
@@ -3316,6 +3401,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
+ "webpki-roots 1.0.7",
]
[[package]]
@@ -3520,6 +3606,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
+ "web-time",
"zeroize",
]
diff --git a/Cargo.toml b/Cargo.toml
index 243ff2b..14eb5df 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -37,6 +37,8 @@ dotenvy = { version = "0.15" }
validator = { version = "0.20" }
uuid = { version = "1.6", features = ["v4"] }
include_dir = { version = "0.7" }
+# outbound HTTP for carrier shipment APIs (Packeta / DPD / DHL)
+reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
# view engine i18n
fluent-templates = { version = "0.13", features = ["tera"] }
unic-langid = { version = "0.9" }
diff --git a/assets/i18n/en/main.ftl b/assets/i18n/en/main.ftl
index 6998732..03d1e73 100644
--- a/assets/i18n/en/main.ftl
+++ b/assets/i18n/en/main.ftl
@@ -261,8 +261,21 @@ bank-account-name = Account holder
bank-variable-symbol = Variable symbol
bank-amount = Amount
admin-shipping = Shipping
-admin-shipping-desc = add, edit and remove delivery options.
+admin-shipping-desc = set the price and availability of each delivery option.
shipping-enabled = Active
shipping-new = Add delivery option
shipping-add = Add
shipping-requires-pickup = Requires pickup point
+shipping-carrier = Carrier
+carrier-none = Manual (no API)
+carrier-packeta = Packeta
+carrier-dpd = DPD
+carrier-dhl = DHL
+order-fulfillment = Fulfillment
+order-shipped-via = Sent via
+order-tracking = Tracking
+order-label = Print label
+order-manual-fulfillment = Manual fulfilment — no carrier API for this option.
+order-send-hint = When the goods are ready, send this order to the carrier.
+order-send-to-carrier = Send to
+order-send-confirm = Send this order to the carrier now?
diff --git a/assets/i18n/sk/main.ftl b/assets/i18n/sk/main.ftl
index 50f8bd6..bd4f87b 100644
--- a/assets/i18n/sk/main.ftl
+++ b/assets/i18n/sk/main.ftl
@@ -261,8 +261,21 @@ bank-account-name = Príjemca
bank-variable-symbol = Variabilný symbol
bank-amount = Suma
admin-shipping = Doprava
-admin-shipping-desc = pridať, upraviť a odstrániť možnosti dopravy.
+admin-shipping-desc = nastaviť cenu a dostupnosť jednotlivých možností dopravy.
shipping-enabled = Aktívne
shipping-new = Pridať možnosť dopravy
shipping-add = Pridať
shipping-requires-pickup = Vyžaduje výdajné miesto
+shipping-carrier = Dopravca
+carrier-none = Manuálne (bez API)
+carrier-packeta = Packeta
+carrier-dpd = DPD
+carrier-dhl = DHL
+order-fulfillment = Expedícia
+order-shipped-via = Odoslané cez
+order-tracking = Sledovanie
+order-label = Tlačiť štítok
+order-manual-fulfillment = Manuálne spracovanie — táto možnosť nemá API dopravcu.
+order-send-hint = Keď je tovar pripravený, odošlite objednávku dopravcovi.
+order-send-to-carrier = Odoslať dopravcovi
+order-send-confirm = Odoslať túto objednávku dopravcovi teraz?
diff --git a/assets/views/admin/orders/show.html b/assets/views/admin/orders/show.html
index 58b0e2f..e60327d 100644
--- a/assets/views/admin/orders/show.html
+++ b/assets/views/admin/orders/show.html
@@ -9,6 +9,12 @@
{{ t(key="admin-orders", lang=lang | default(value='sk')) }}
+{% if ship_error %}
+
+ {{ ship_error }}
+
+{% endif %}
+
@@ -69,6 +75,33 @@
{% endif %}
+
+
{{ t(key="order-fulfillment", lang=lang | default(value='sk')) }}
+ {% if order.tracking_number %}
+
+ {{ t(key="order-shipped-via", lang=lang | default(value='sk')) }} {{ carrier | upper }}
+
+
+ {{ t(key="order-tracking", lang=lang | default(value='sk')) }}: {{ order.tracking_number }}
+
+ {% if order.label_url %}
+
{{ t(key="order-label", lang=lang | default(value='sk')) }}
+ {% endif %}
+ {% elif carrier == "none" %}
+
{{ t(key="order-manual-fulfillment", lang=lang | default(value='sk')) }}
+ {% elif can_ship %}
+
{{ t(key="order-send-hint", lang=lang | default(value='sk')) }}
+
+ {% endif %}
+
+
-
-
{% endblock content %}
diff --git a/config/development.yaml b/config/development.yaml
index cc8ae30..20a66b8 100644
--- a/config/development.yaml
+++ b/config/development.yaml
@@ -108,6 +108,20 @@ settings:
# Packeta (Zásilkovna) web API key for the pickup-point picker widget.
# Empty falls back to a plain text field for the pickup point.
packeta_api_key: {{ get_env(name="PACKETA_API_KEY", default="") }}
+ # Packeta REST API secret + sender label, used by admin "Send to carrier"
+ # (manual shipment creation). See docs/integrations/packeta.md.
+ packeta_api_password: {{ get_env(name="PACKETA_API_PASSWORD", default="") }}
+ packeta_sender_label: {{ get_env(name="PACKETA_SENDER_LABEL", default="") }}
+ # DPD shipment API (see docs/integrations/dpd.md). Empty = not configured.
+ dpd_api_base: {{ get_env(name="DPD_API_BASE", default="") }}
+ dpd_login: {{ get_env(name="DPD_LOGIN", default="") }}
+ dpd_password: {{ get_env(name="DPD_PASSWORD", default="") }}
+ dpd_customer_number: {{ get_env(name="DPD_CUSTOMER_NUMBER", default="") }}
+ # DHL shipment API (see docs/integrations/dhl.md). Empty = not configured.
+ dhl_api_base: {{ get_env(name="DHL_API_BASE", default="") }}
+ dhl_api_key: {{ get_env(name="DHL_API_KEY", default="") }}
+ dhl_api_secret: {{ get_env(name="DHL_API_SECRET", default="") }}
+ dhl_account_number: {{ get_env(name="DHL_ACCOUNT_NUMBER", default="") }}
# Bank-transfer payment details shown on the order confirmation.
bank_iban: {{ get_env(name="BANK_IBAN", default="SK00 0000 0000 0000 0000 0000") }}
bank_account_name: {{ get_env(name="BANK_ACCOUNT_NAME", default="Kompress s.r.o.") }}
diff --git a/docs/integrations/README.md b/docs/integrations/README.md
index e0f138f..65e00a8 100644
--- a/docs/integrations/README.md
+++ b/docs/integrations/README.md
@@ -35,12 +35,18 @@ home-delivery option that has no pickup point at all.
| Order stores carrier + pickup point | `orders` table (`carrier_code`, `carrier_name`, `pickup_point_id`, `pickup_point_name`, `shipping_cents`) | ✅ done |
| Settings lookup | `src/shared/settings.rs` → reads `settings.*` from `config/*.yaml` | ✅ done |
| Packeta pickup-point widget | `assets/views/shop/checkout.html` (loads when `packeta_api_key` set) | ✅ scaffolded |
-| Shipment-creation API client (any carrier) | — | ❌ not built |
-| Tracking number on order | — | ❌ not built |
+| `shipping_methods.carrier` (which API a method maps to) | `migration/.../m20260617_000001_*` + admin add-form dropdown | ✅ done |
+| Tracking / shipment id / label on order | `migration/.../m20260617_000002_*` (`orders.tracking_number`, `shipment_id`, `label_url`) | ✅ done |
+| Manual "Send to carrier" admin action | `src/controllers/admin_orders.rs` (`ship`), order detail page | ✅ done |
+| Carrier client dispatch | `src/integrations/` (`create_shipment`) | ✅ done |
+| Packeta shipment client | `src/integrations/packeta.rs` (real `createPacket`) | ✅ done |
+| DPD / DHL shipment clients | `src/integrations/dpd.rs`, `dhl.rs` | 🟡 credential-guarded stub — fill in HTTP call per contract |
-So **pickup-point selection for Packeta is already wired** — it just needs an
-API key. Everything else (DPD/DHL widgets, and *all* shipment-creation API
-calls) is new work, described per carrier.
+**Shipments are created only when an admin clicks "Send to carrier" on the order
+page** — never automatically at checkout. Packeta is wired end-to-end (needs
+just the API password + sender label). DPD/DHL run through the same flow but
+their HTTP body must be finalised against your contract (clearly marked TODOs in
+each file).
## Shared groundwork (do this once, before any carrier's API step)
diff --git a/migration/src/lib.rs b/migration/src/lib.rs
index cf14d30..797109f 100644
--- a/migration/src/lib.rs
+++ b/migration/src/lib.rs
@@ -27,6 +27,8 @@ mod m20260616_132000_drop_blog_and_pages;
mod m20260616_150755_shipping_methods;
mod m20260616_150812_add_shipping_fields_to_orders;
mod m20260616_160000_add_parent_to_categories;
+mod m20260617_000001_add_carrier_to_shipping_methods;
+mod m20260617_000002_add_shipment_to_orders;
pub struct Migrator;
#[async_trait::async_trait]
@@ -58,6 +60,8 @@ impl MigratorTrait for Migrator {
Box::new(m20260616_150755_shipping_methods::Migration),
Box::new(m20260616_150812_add_shipping_fields_to_orders::Migration),
Box::new(m20260616_160000_add_parent_to_categories::Migration),
+ Box::new(m20260617_000001_add_carrier_to_shipping_methods::Migration),
+ Box::new(m20260617_000002_add_shipment_to_orders::Migration),
// inject-above (do not remove this comment)
]
}
diff --git a/migration/src/m20260617_000001_add_carrier_to_shipping_methods.rs b/migration/src/m20260617_000001_add_carrier_to_shipping_methods.rs
new file mode 100644
index 0000000..2f9ddf5
--- /dev/null
+++ b/migration/src/m20260617_000001_add_carrier_to_shipping_methods.rs
@@ -0,0 +1,24 @@
+use loco_rs::schema::*;
+use sea_orm_migration::prelude::*;
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+ async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
+ // Which carrier API (if any) a delivery option maps to. "none" means the
+ // option is fulfilled manually and never calls an external API.
+ add_column(
+ m,
+ "shipping_methods",
+ "carrier",
+ ColType::StringWithDefault("none".to_string()),
+ )
+ .await
+ }
+
+ async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
+ remove_column(m, "shipping_methods", "carrier").await
+ }
+}
diff --git a/migration/src/m20260617_000002_add_shipment_to_orders.rs b/migration/src/m20260617_000002_add_shipment_to_orders.rs
new file mode 100644
index 0000000..a428183
--- /dev/null
+++ b/migration/src/m20260617_000002_add_shipment_to_orders.rs
@@ -0,0 +1,23 @@
+use loco_rs::schema::*;
+use sea_orm_migration::prelude::*;
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+ async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
+ // Populated only after an admin manually sends the order to a carrier.
+ add_column(m, "orders", "tracking_number", ColType::StringNull).await?;
+ add_column(m, "orders", "shipment_id", ColType::StringNull).await?;
+ add_column(m, "orders", "label_url", ColType::StringNull).await?;
+ Ok(())
+ }
+
+ async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
+ remove_column(m, "orders", "tracking_number").await?;
+ remove_column(m, "orders", "shipment_id").await?;
+ remove_column(m, "orders", "label_url").await?;
+ Ok(())
+ }
+}
diff --git a/src/app.rs b/src/app.rs
index 7ff1583..6cd319f 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -60,6 +60,7 @@ impl Hooks for App {
Ok(vec![
Box::new(initializers::view_engine::ViewEngineInitializer),
Box::new(initializers::admin_seeder::AdminSeeder),
+ Box::new(initializers::shipping_seeder::ShippingSeeder),
])
}
diff --git a/src/controllers/admin_orders.rs b/src/controllers/admin_orders.rs
index 9259b3a..bddeb74 100644
--- a/src/controllers/admin_orders.rs
+++ b/src/controllers/admin_orders.rs
@@ -1,4 +1,4 @@
-//! Admin order list, detail, and status updates.
+//! Admin order list, detail, status updates, and manual carrier dispatch.
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
@@ -7,7 +7,8 @@ use serde::Deserialize;
use serde_json::json;
use crate::{
- models::{order_items, orders},
+ integrations::{self, ShipmentRequest},
+ models::{order_items, orders, shipping_methods},
views::checkout as view,
controllers::i18n::current_lang,
shared::{guard, settings},
@@ -15,6 +16,9 @@ use crate::{
pub(crate) const ORDER_STATUSES: [&str; 4] = ["pending", "paid", "shipped", "cancelled"];
+/// Fallback parcel weight when products carry no weight of their own.
+const DEFAULT_PARCEL_WEIGHT_GRAMS: i32 = 1000;
+
#[derive(Debug, Deserialize)]
struct StatusForm {
status: String,
@@ -40,15 +44,28 @@ async fn index(
)
}
-#[debug_handler]
-async fn show(
- auth: auth::JWT,
- jar: CookieJar,
- ViewEngine(v): ViewEngine
,
- Path(id): Path,
- State(ctx): State,
+/// Resolve the carrier code (`none`/`packeta`/`dpd`/`dhl`) for an order from its
+/// chosen shipping method, defaulting to `none` when unknown.
+async fn order_carrier(ctx: &AppContext, order: &orders::Model) -> Result {
+ let Some(code) = order.carrier_code.as_deref() else {
+ return Ok("none".to_string());
+ };
+ Ok(shipping_methods::Entity::find()
+ .filter(shipping_methods::Column::Code.eq(code))
+ .one(&ctx.db)
+ .await?
+ .map(|m| m.carrier)
+ .unwrap_or_else(|| "none".to_string()))
+}
+
+/// Render the order detail page, optionally with a dispatch error banner.
+async fn render_show(
+ jar: &CookieJar,
+ v: &TeraView,
+ ctx: &AppContext,
+ id: i32,
+ error: Option,
) -> Result {
- guard::current_admin(auth, &ctx).await?;
let order = orders::Entity::find_by_id(id)
.one(&ctx.db)
.await?
@@ -58,22 +75,42 @@ async fn show(
.all(&ctx.db)
.await?;
+ let carrier = order_carrier(ctx, &order).await?;
+ // The order can be sent only if it maps to a real carrier and hasn't been
+ // dispatched yet.
+ let can_ship = carrier != "none" && order.tracking_number.is_none();
+
format::view(
- &v,
+ v,
"admin/orders/show.html",
json!({
"order": view::detail(
&order,
- settings::get(&ctx, "bank_iban").unwrap_or(""),
- settings::get(&ctx, "bank_account_name").unwrap_or(""),
+ settings::get(ctx, "bank_iban").unwrap_or(""),
+ settings::get(ctx, "bank_account_name").unwrap_or(""),
),
"items": view::items(&items),
"statuses": ORDER_STATUSES,
- "lang": current_lang(&jar),
+ "carrier": carrier,
+ "can_ship": can_ship,
+ "ship_error": error,
+ "lang": current_lang(jar),
}),
)
}
+#[debug_handler]
+async fn show(
+ auth: auth::JWT,
+ jar: CookieJar,
+ ViewEngine(v): ViewEngine,
+ Path(id): Path,
+ State(ctx): State,
+) -> Result {
+ guard::current_admin(auth, &ctx).await?;
+ render_show(&jar, &v, &ctx, id, None).await
+}
+
#[debug_handler]
async fn update_status(
auth: auth::JWT,
@@ -96,9 +133,82 @@ async fn update_status(
format::redirect(&format!("/admin/orders/{id}"))
}
+/// Manually dispatch an order to its carrier. This is the *only* place that
+/// calls a carrier API, and it is triggered exclusively by an admin clicking
+/// "Send to carrier" after the goods are verified and ready.
+#[debug_handler]
+async fn ship(
+ auth: auth::JWT,
+ jar: CookieJar,
+ ViewEngine(v): ViewEngine,
+ Path(id): Path,
+ State(ctx): State,
+) -> Result {
+ guard::current_admin(auth, &ctx).await?;
+ let order = orders::Entity::find_by_id(id)
+ .one(&ctx.db)
+ .await?
+ .ok_or_else(|| Error::NotFound)?;
+
+ // Idempotency: never create a second shipment for an already-dispatched order.
+ if order.tracking_number.is_some() {
+ return render_show(
+ &jar,
+ &v,
+ &ctx,
+ id,
+ Some("This order has already been sent to the carrier.".to_string()),
+ )
+ .await;
+ }
+
+ let carrier = order_carrier(&ctx, &order).await?;
+ let goods_value = (order.total_cents - order.shipping_cents).max(0);
+ let cod_cents = match order.payment_method.as_deref() {
+ Some("cod") => order.total_cents,
+ _ => 0,
+ };
+ let recipient = order
+ .customer_name
+ .as_deref()
+ .filter(|s| !s.is_empty())
+ .unwrap_or(&order.email);
+
+ let req = ShipmentRequest {
+ order_number: &order.order_number,
+ recipient_name: recipient,
+ email: &order.email,
+ address: order.address.as_deref(),
+ city: order.city.as_deref(),
+ zip: order.zip.as_deref(),
+ country: order.country.as_deref(),
+ pickup_point_id: order.pickup_point_id.as_deref(),
+ cod_cents,
+ currency: &order.currency,
+ value_cents: goods_value,
+ weight_grams: DEFAULT_PARCEL_WEIGHT_GRAMS,
+ };
+
+ match integrations::create_shipment(&ctx, &carrier, req).await {
+ Ok(result) => {
+ let mut active = order.into_active_model();
+ active.tracking_number = Set(Some(result.tracking_number));
+ active.shipment_id = Set(Some(result.shipment_id));
+ active.label_url = Set(result.label_url);
+ active.status = Set("shipped".to_string());
+ active.update(&ctx.db).await?;
+ format::redirect(&format!("/admin/orders/{id}"))
+ }
+ // Show the carrier's error in-page rather than a generic error screen,
+ // so the admin can fix the cause and retry.
+ Err(err) => render_show(&jar, &v, &ctx, id, Some(err.to_string())).await,
+ }
+}
+
pub fn routes() -> Routes {
Routes::new()
.add("/admin/orders", get(index))
.add("/admin/orders/{id}", get(show))
.add("/admin/orders/{id}/status", post(update_status))
+ .add("/admin/orders/{id}/ship", post(ship))
}
diff --git a/src/controllers/admin_shipping.rs b/src/controllers/admin_shipping.rs
index f60bf8c..ba7b387 100644
--- a/src/controllers/admin_shipping.rs
+++ b/src/controllers/admin_shipping.rs
@@ -1,11 +1,12 @@
-//! Admin management of shipping methods: add, edit (price + enabled), remove.
+//! Admin management of the built-in delivery options (Packeta, DPD).
+//!
+//! The options themselves are fixed and seeded by `initializers::shipping_seeder`
+//! — they cannot be added or removed here. The admin only sets each one's price
+//! and toggles whether it is offered at checkout.
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
-use sea_orm::{
- ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter,
- QueryOrder, Set,
-};
+use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set};
use serde::Deserialize;
use serde_json::json;
@@ -15,7 +16,6 @@ use crate::{
shared::{
guard,
money::{format_price, parse_price_to_cents},
- slug::{slugify, unique_slug},
},
};
@@ -25,14 +25,6 @@ struct ShippingForm {
enabled: Option,
}
-#[derive(Debug, Deserialize)]
-struct NewShippingForm {
- name: String,
- price: String,
- requires_pickup_point: Option,
- enabled: Option,
-}
-
fn is_checked(value: &Option) -> bool {
matches!(value.as_deref(), Some("on" | "true" | "1"))
}
@@ -57,6 +49,7 @@ async fn index(
"code": m.code,
"name": m.name,
"price": format_price(m.price_cents),
+ "carrier": m.carrier,
"requires_pickup_point": m.requires_pickup_point,
"enabled": m.enabled,
})
@@ -69,48 +62,6 @@ async fn index(
)
}
-#[debug_handler]
-async fn create(
- auth: auth::JWT,
- State(ctx): State,
- Form(form): Form,
-) -> Result {
- guard::current_admin(auth, &ctx).await?;
- let name = form.name.trim().to_string();
- if name.is_empty() {
- return Err(Error::BadRequest("name is required".to_string()));
- }
- // Stable unique `code` derived from the name; it's what checkout submits and
- // what an order stores, so it must not collide with an existing method.
- let code = unique_slug(&slugify(&name), |candidate| {
- let ctx = ctx.clone();
- async move {
- Ok(shipping_methods::Entity::find()
- .filter(shipping_methods::Column::Code.eq(candidate))
- .count(&ctx.db)
- .await?
- > 0)
- }
- })
- .await?;
- // Append after existing methods.
- let position = shipping_methods::Entity::find().count(&ctx.db).await? as i32;
-
- shipping_methods::ActiveModel {
- code: Set(code),
- name: Set(name),
- price_cents: Set(parse_price_to_cents(&form.price)?),
- requires_pickup_point: Set(is_checked(&form.requires_pickup_point)),
- enabled: Set(is_checked(&form.enabled)),
- position: Set(position),
- ..Default::default()
- }
- .insert(&ctx.db)
- .await?;
-
- format::redirect("/admin/shipping")
-}
-
#[debug_handler]
async fn update(
auth: auth::JWT,
@@ -130,25 +81,8 @@ async fn update(
format::redirect("/admin/shipping")
}
-#[debug_handler]
-async fn delete(
- auth: auth::JWT,
- Path(id): Path,
- State(ctx): State,
-) -> Result {
- guard::current_admin(auth, &ctx).await?;
- let method = shipping_methods::Entity::find_by_id(id)
- .one(&ctx.db)
- .await?
- .ok_or_else(|| Error::NotFound)?;
- method.delete(&ctx.db).await?;
- format::redirect("/admin/shipping")
-}
-
pub fn routes() -> Routes {
Routes::new()
.add("/admin/shipping", get(index))
- .add("/admin/shipping", post(create))
.add("/admin/shipping/{id}", post(update))
- .add("/admin/shipping/{id}/delete", post(delete))
}
diff --git a/src/initializers/mod.rs b/src/initializers/mod.rs
index a5780a7..0f2a40c 100644
--- a/src/initializers/mod.rs
+++ b/src/initializers/mod.rs
@@ -1,2 +1,3 @@
pub mod admin_seeder;
+pub mod shipping_seeder;
pub mod view_engine;
diff --git a/src/initializers/shipping_seeder.rs b/src/initializers/shipping_seeder.rs
new file mode 100644
index 0000000..d1dfa5c
--- /dev/null
+++ b/src/initializers/shipping_seeder.rs
@@ -0,0 +1,54 @@
+//! Ensures the built-in carrier delivery options (Packeta, DPD) always exist.
+//!
+//! These are the only delivery options the shop offers; the admin can price and
+//! enable/disable them but cannot add or remove options. We insert each one
+//! only when its `code` is missing, so an admin's price/enabled changes are
+//! never overwritten on the next boot.
+
+use async_trait::async_trait;
+use loco_rs::prelude::*;
+use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
+
+use crate::models::shipping_methods;
+
+/// `(code, name, carrier, requires_pickup_point, default_price_cents, position)`
+const BUILTINS: [(&str, &str, &str, bool, i64, i32); 2] = [
+ ("packeta", "Packeta", "packeta", true, 290, 0),
+ ("dpd", "DPD", "dpd", false, 450, 1),
+];
+
+pub struct ShippingSeeder;
+
+#[async_trait]
+impl Initializer for ShippingSeeder {
+ fn name(&self) -> String {
+ "shipping-seeder".to_string()
+ }
+
+ async fn before_run(&self, ctx: &AppContext) -> Result<()> {
+ for (code, name, carrier, requires_pickup_point, price_cents, position) in BUILTINS {
+ let exists = shipping_methods::Entity::find()
+ .filter(shipping_methods::Column::Code.eq(code))
+ .count(&ctx.db)
+ .await?
+ > 0;
+ if exists {
+ continue;
+ }
+ shipping_methods::ActiveModel {
+ code: Set(code.to_string()),
+ name: Set(name.to_string()),
+ carrier: Set(carrier.to_string()),
+ requires_pickup_point: Set(requires_pickup_point),
+ price_cents: Set(price_cents),
+ enabled: Set(true),
+ position: Set(position),
+ ..Default::default()
+ }
+ .insert(&ctx.db)
+ .await?;
+ tracing::info!(carrier = code, "seeded built-in delivery option");
+ }
+ Ok(())
+ }
+}
diff --git a/src/integrations/dhl.rs b/src/integrations/dhl.rs
new file mode 100644
index 0000000..1013638
--- /dev/null
+++ b/src/integrations/dhl.rs
@@ -0,0 +1,35 @@
+//! DHL shipment creation. See `docs/integrations/dhl.md`.
+//!
+//! DHL has several APIs (Parcel DE Shipping, eCommerce, MyDHL Express) behind
+//! one developer portal; which one applies depends on your contract and the
+//! markets you ship to. As with DPD, the workflow is fully wired — only the
+//! authenticated HTTP call is left as a marked TODO so we don't ship an
+//! unverified payload.
+
+use loco_rs::prelude::*;
+
+use super::{ShipmentRequest, ShipmentResult};
+use crate::shared::settings;
+
+pub async fn create_shipment(ctx: &AppContext, _req: ShipmentRequest<'_>) -> Result {
+ let _base = settings::get(ctx, "dhl_api_base").filter(|s| !s.is_empty());
+ let _key = settings::get(ctx, "dhl_api_key").filter(|s| !s.is_empty());
+ let _secret = settings::get(ctx, "dhl_api_secret").filter(|s| !s.is_empty());
+ let _account = settings::get(ctx, "dhl_account_number").filter(|s| !s.is_empty());
+
+ if _base.is_none() || _key.is_none() || _secret.is_none() || _account.is_none() {
+ return Err(Error::BadRequest(
+ "DHL is not configured: set settings.dhl_api_base / dhl_api_key / dhl_api_secret / dhl_account_number (see docs/integrations/dhl.md)".to_string(),
+ ));
+ }
+
+ // TODO(dhl): implement once the API subscription is known:
+ // 1. OAuth2 client-credentials -> Bearer token (cache until expiry).
+ // 2. POST the shipment: shipper = your account/EKP, consignee from
+ // `_req`, product code (domestic/express), weight, references; add
+ // customs data for non-EU destinations.
+ // 3. Parse tracking number + label, return ShipmentResult.
+ Err(Error::BadRequest(
+ "DHL shipment API not finalised yet — fill in the request in src/integrations/dhl.rs per your DHL subscription (docs/integrations/dhl.md)".to_string(),
+ ))
+}
diff --git a/src/integrations/dpd.rs b/src/integrations/dpd.rs
new file mode 100644
index 0000000..0f00c39
--- /dev/null
+++ b/src/integrations/dpd.rs
@@ -0,0 +1,38 @@
+//! DPD shipment creation. See `docs/integrations/dpd.md`.
+//!
+//! DPD's API (REST vs SOAP, base URL, exact field names) depends on your
+//! country and contract, so the request body below must be finalised against
+//! *your* DPD account before going live. The surrounding workflow — admin
+//! trigger, tracking storage, status update — is fully wired; only the HTTP
+//! call is left as a clearly-marked TODO so we don't ship an unverified payload
+//! that silently produces broken shipments.
+
+use loco_rs::prelude::*;
+
+use super::{ShipmentRequest, ShipmentResult};
+use crate::shared::settings;
+
+pub async fn create_shipment(ctx: &AppContext, _req: ShipmentRequest<'_>) -> Result {
+ // Required settings (add to config/*.yaml under `settings:` — see docs).
+ let _base = settings::get(ctx, "dpd_api_base").filter(|s| !s.is_empty());
+ let _login = settings::get(ctx, "dpd_login").filter(|s| !s.is_empty());
+ let _password = settings::get(ctx, "dpd_password").filter(|s| !s.is_empty());
+ let _customer = settings::get(ctx, "dpd_customer_number").filter(|s| !s.is_empty());
+
+ if _base.is_none() || _login.is_none() || _password.is_none() || _customer.is_none() {
+ return Err(Error::BadRequest(
+ "DPD is not configured: set settings.dpd_api_base / dpd_login / dpd_password / dpd_customer_number (see docs/integrations/dpd.md)".to_string(),
+ ));
+ }
+
+ // TODO(dpd): implement once the contract's API variant is known:
+ // 1. POST login (delisId + password) -> auth token (cache it).
+ // 2. POST storeOrders with the token: recipient from `_req` (or
+ // parcelShopId = _req.pickup_point_id for DPD Pickup), weight,
+ // cod = _req.cod_cents, reference = _req.order_number.
+ // 3. Parse the returned parcel number (tracking) + label, then return:
+ // Ok(ShipmentResult { shipment_id, tracking_number, label_url })
+ Err(Error::BadRequest(
+ "DPD shipment API not finalised yet — fill in the request in src/integrations/dpd.rs per your DPD contract (docs/integrations/dpd.md)".to_string(),
+ ))
+}
diff --git a/src/integrations/mod.rs b/src/integrations/mod.rs
new file mode 100644
index 0000000..4d20173
--- /dev/null
+++ b/src/integrations/mod.rs
@@ -0,0 +1,65 @@
+//! Outbound carrier integrations for creating shipments.
+//!
+//! Shipments are **never created automatically**. An admin reviews an order and,
+//! once the goods are physically ready, explicitly triggers
+//! [`create_shipment`] from the order page. Only then does the eshop call the
+//! carrier's API. `orders::place` (checkout) does not touch any of this.
+//!
+//! Each delivery option (`shipping_methods.carrier`) maps to one carrier here.
+//! "none" means the option is fulfilled manually and has no API.
+
+pub mod dhl;
+pub mod dpd;
+pub mod packeta;
+
+use loco_rs::prelude::*;
+
+/// Everything a carrier needs to register a parcel, snapshotted from an order.
+pub struct ShipmentRequest<'a> {
+ pub order_number: &'a str,
+ pub recipient_name: &'a str,
+ pub email: &'a str,
+ pub address: Option<&'a str>,
+ pub city: Option<&'a str>,
+ pub zip: Option<&'a str>,
+ pub country: Option<&'a str>,
+ /// Carrier pickup-point / locker id, when the method requires one.
+ pub pickup_point_id: Option<&'a str>,
+ /// Cash-on-delivery amount in cents; `0` when payment is not COD.
+ pub cod_cents: i64,
+ pub currency: &'a str,
+ /// Total order value in cents (for insurance / customs declarations).
+ pub value_cents: i64,
+ pub weight_grams: i32,
+}
+
+/// What a carrier returns once the parcel is registered.
+pub struct ShipmentResult {
+ /// Carrier-internal shipment/packet id.
+ pub shipment_id: String,
+ /// Public tracking number / barcode shown to the customer.
+ pub tracking_number: String,
+ /// Direct link to the shipping label PDF, if the carrier returns one.
+ pub label_url: Option,
+}
+
+/// Dispatch to the carrier named by `shipping_methods.carrier`. Returns an error
+/// for `"none"` (manual fulfilment) or an unknown carrier.
+pub async fn create_shipment(
+ ctx: &AppContext,
+ carrier: &str,
+ req: ShipmentRequest<'_>,
+) -> Result {
+ match carrier {
+ "packeta" => packeta::create_shipment(ctx, req).await,
+ "dpd" => dpd::create_shipment(ctx, req).await,
+ "dhl" => dhl::create_shipment(ctx, req).await,
+ "none" | "" => Err(Error::BadRequest(
+ "this delivery option is fulfilled manually (no carrier API)".to_string(),
+ )),
+ other => Err(Error::BadRequest(format!("unknown carrier '{other}'"))),
+ }
+}
+
+/// The carrier values offered in the admin UI. `none` is the manual default.
+pub const CARRIERS: [&str; 4] = ["none", "packeta", "dpd", "dhl"];
diff --git a/src/integrations/packeta.rs b/src/integrations/packeta.rs
new file mode 100644
index 0000000..21ea1b6
--- /dev/null
+++ b/src/integrations/packeta.rs
@@ -0,0 +1,114 @@
+//! Packeta (Zásilkovna) shipment creation via the REST `createPacket` call.
+//!
+//! See `docs/integrations/packeta.md`. Requires two settings:
+//! - `packeta_api_password` — the secret REST API password (NOT the widget key)
+//! - `packeta_sender_label` — your sender/eshop label configured in the portal
+
+use loco_rs::prelude::*;
+
+use super::{ShipmentRequest, ShipmentResult};
+use crate::shared::settings;
+
+const ENDPOINT: &str = "https://www.zasilkovna.cz/api/rest";
+
+/// Minimal XML-entity escaping for values interpolated into the request body.
+fn xml_escape(value: &str) -> String {
+ value
+ .replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+ .replace('\'', "'")
+}
+
+/// Extract the text inside the first `…` pair, if present.
+fn extract(xml: &str, tag: &str) -> Option {
+ let open = format!("<{tag}>");
+ let close = format!("{tag}>");
+ let start = xml.find(&open)? + open.len();
+ let end = xml[start..].find(&close)? + start;
+ Some(xml[start..end].trim().to_string())
+}
+
+pub async fn create_shipment(ctx: &AppContext, req: ShipmentRequest<'_>) -> Result {
+ let api_password = settings::get(ctx, "packeta_api_password").filter(|s| !s.is_empty()).ok_or_else(|| {
+ Error::BadRequest(
+ "Packeta is not configured: set settings.packeta_api_password (see docs/integrations/packeta.md)".to_string(),
+ )
+ })?;
+ let sender_label = settings::get(ctx, "packeta_sender_label").filter(|s| !s.is_empty()).ok_or_else(|| {
+ Error::BadRequest(
+ "Packeta is not configured: set settings.packeta_sender_label (see docs/integrations/packeta.md)".to_string(),
+ )
+ })?;
+
+ // The scaffolded checkout flow delivers to a Packeta pickup point, so an
+ // address id is required. (Home delivery uses a different routing flow.)
+ let address_id = req.pickup_point_id.filter(|s| !s.is_empty()).ok_or_else(|| {
+ Error::BadRequest("this order has no Packeta pickup point selected".to_string())
+ })?;
+
+ let value = req.value_cents as f64 / 100.0;
+ let cod = req.cod_cents as f64 / 100.0;
+ let weight_kg = f64::from(req.weight_grams) / 1000.0;
+
+ let body = format!(
+ "\
+ {}\
+ \
+ {}\
+ {}\
+ -\
+ {}\
+ {}\
+ {:.2}\
+ {:.2}\
+ {}\
+ {:.3}\
+ {}\
+ \
+ ",
+ xml_escape(api_password),
+ xml_escape(req.order_number),
+ xml_escape(req.recipient_name),
+ xml_escape(req.email),
+ xml_escape(address_id),
+ value,
+ cod,
+ xml_escape(req.currency),
+ weight_kg,
+ xml_escape(sender_label),
+ );
+
+ let resp = reqwest::Client::new()
+ .post(ENDPOINT)
+ .header("Content-Type", "text/xml; charset=utf-8")
+ .body(body)
+ .send()
+ .await
+ .map_err(|e| Error::string(&format!("Packeta request failed: {e}")))?;
+
+ let text = resp
+ .text()
+ .await
+ .map_err(|e| Error::string(&format!("Packeta response read failed: {e}")))?;
+
+ // A successful response is ok….
+ // A failure carries fault plus a / message.
+ if extract(&text, "status").as_deref() != Some("ok") {
+ let message = extract(&text, "string")
+ .or_else(|| extract(&text, "fault"))
+ .unwrap_or_else(|| "unknown Packeta error".to_string());
+ return Err(Error::BadRequest(format!("Packeta rejected the shipment: {message}")));
+ }
+
+ let shipment_id = extract(&text, "id")
+ .ok_or_else(|| Error::string("Packeta response missing packet id"))?;
+ let tracking_number = extract(&text, "barcode").unwrap_or_else(|| shipment_id.clone());
+
+ Ok(ShipmentResult {
+ label_url: Some(format!("https://tracking.packeta.com/sk/?id={tracking_number}")),
+ shipment_id,
+ tracking_number,
+ })
+}
diff --git a/src/lib.rs b/src/lib.rs
index c96ed98..2815ec3 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -2,6 +2,7 @@ pub mod app;
pub mod controllers;
pub mod data;
pub mod initializers;
+pub mod integrations;
pub mod mailers;
pub mod models;
pub mod seed;
diff --git a/src/models/_entities/orders.rs b/src/models/_entities/orders.rs
index b52e55a..2cabdd9 100644
--- a/src/models/_entities/orders.rs
+++ b/src/models/_entities/orders.rs
@@ -29,6 +29,9 @@ pub struct Model {
pub shipping_cents: i64,
pub pickup_point_id: Option,
pub pickup_point_name: Option,
+ pub tracking_number: Option,
+ pub shipment_id: Option,
+ pub label_url: Option,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
diff --git a/src/models/_entities/shipping_methods.rs b/src/models/_entities/shipping_methods.rs
index 9f4f904..18aca2a 100644
--- a/src/models/_entities/shipping_methods.rs
+++ b/src/models/_entities/shipping_methods.rs
@@ -17,6 +17,7 @@ pub struct Model {
pub requires_pickup_point: bool,
pub enabled: bool,
pub position: i32,
+ pub carrier: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
diff --git a/src/views/checkout.rs b/src/views/checkout.rs
index b8ae5f5..b9a7750 100644
--- a/src/views/checkout.rs
+++ b/src/views/checkout.rs
@@ -42,6 +42,9 @@ pub fn detail(order: &orders::Model, bank_iban: &str, bank_account_name: &str) -
"payment_method": order.payment_method,
"carrier_name": order.carrier_name,
"pickup_point_name": order.pickup_point_name,
+ "tracking_number": order.tracking_number,
+ "shipment_id": order.shipment_id,
+ "label_url": order.label_url,
// Numeric, sequential order id doubles as the bank variable symbol.
"variable_symbol": order.id,
"bank_iban": bank_iban,