From cd7a756a540f6099ca7b76dbcf461f1308f018b7 Mon Sep 17 00:00:00 2001 From: Priec Date: Wed, 17 Jun 2026 17:27:19 +0200 Subject: [PATCH] hardcode of dpd and packeta --- Cargo.lock | 87 +++++++++++ Cargo.toml | 2 + assets/i18n/en/main.ftl | 15 +- assets/i18n/sk/main.ftl | 15 +- assets/views/admin/orders/show.html | 33 +++++ assets/views/admin/shipping/index.html | 79 +++------- config/development.yaml | 14 ++ docs/integrations/README.md | 16 +- migration/src/lib.rs | 4 + ..._000001_add_carrier_to_shipping_methods.rs | 24 +++ ...m20260617_000002_add_shipment_to_orders.rs | 23 +++ src/app.rs | 1 + src/controllers/admin_orders.rs | 138 ++++++++++++++++-- src/controllers/admin_shipping.rs | 80 +--------- src/initializers/mod.rs | 1 + src/initializers/shipping_seeder.rs | 54 +++++++ src/integrations/dhl.rs | 35 +++++ src/integrations/dpd.rs | 38 +++++ src/integrations/mod.rs | 65 +++++++++ src/integrations/packeta.rs | 114 +++++++++++++++ src/lib.rs | 1 + src/models/_entities/orders.rs | 3 + src/models/_entities/shipping_methods.rs | 1 + src/views/checkout.rs | 3 + 24 files changed, 694 insertions(+), 152 deletions(-) create mode 100644 migration/src/m20260617_000001_add_carrier_to_shipping_methods.rs create mode 100644 migration/src/m20260617_000002_add_shipment_to_orders.rs create mode 100644 src/initializers/shipping_seeder.rs create mode 100644 src/integrations/dhl.rs create mode 100644 src/integrations/dpd.rs create mode 100644 src/integrations/mod.rs create mode 100644 src/integrations/packeta.rs 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 %} +
+
-
- - - -
- -
-
+
+
+

{{ method.name }}

+

{{ method.carrier | upper }}{% if method.requires_pickup_point %} · {{ t(key="checkout-pickup-point", lang=lang | default(value='sk')) }}{% endif %}

+
+
+ + +
+ + +
{% endfor %} - -
-

{{ t(key="shipping-new", lang=lang | default(value='sk')) }}

-
- - -
-
- - -
- - - -
{% 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!(""); + 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,