diff --git a/migration/src/lib.rs b/migration/src/lib.rs
index d8eb988..0b63257 100644
--- a/migration/src/lib.rs
+++ b/migration/src/lib.rs
@@ -51,6 +51,7 @@ mod m20260623_000003_drop_currency;
mod m20260623_000004_currencies;
mod m20260625_000001_add_avatar_to_users;
mod m20260627_000001_order_residence_address;
+mod m20260627_000002_payment_settings;
pub struct Migrator;
#[async_trait::async_trait]
@@ -106,6 +107,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260623_000004_currencies::Migration),
Box::new(m20260625_000001_add_avatar_to_users::Migration),
Box::new(m20260627_000001_order_residence_address::Migration),
+ Box::new(m20260627_000002_payment_settings::Migration),
// inject-above (do not remove this comment)
]
}
diff --git a/migration/src/m20260627_000002_payment_settings.rs b/migration/src/m20260627_000002_payment_settings.rs
new file mode 100644
index 0000000..11abc2a
--- /dev/null
+++ b/migration/src/m20260627_000002_payment_settings.rs
@@ -0,0 +1,41 @@
+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> {
+ create_table(
+ m,
+ "payment_methods",
+ &[
+ ("id", ColType::PkAuto),
+ ("code", ColType::StringUniq),
+ ("name", ColType::String),
+ ("enabled", ColType::BooleanWithDefault(true)),
+ ("position", ColType::IntegerWithDefault(0)),
+ ],
+ &[],
+ )
+ .await?;
+
+ create_table(
+ m,
+ "shop_settings",
+ &[
+ ("id", ColType::PkAuto),
+ ("key", ColType::StringUniq),
+ ("value", ColType::TextNull),
+ ],
+ &[],
+ )
+ .await
+ }
+
+ async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
+ drop_table(m, "shop_settings").await?;
+ drop_table(m, "payment_methods").await
+ }
+}
diff --git a/src/app.rs b/src/app.rs
index 07a0c1f..6d1364a 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -18,7 +18,7 @@ use std::{path::Path, sync::Arc};
use crate::{
controllers::{
account, admin_categories, admin_currencies, admin_customers, admin_dashboard,
- admin_discount_profiles, admin_form, admin_orders, admin_products, admin_shipping,
+ admin_discount_profiles, admin_form, admin_orders, admin_payments, admin_products, admin_shipping,
auth, auth_pages, cart, checkout, currency, home, i18n, media, oauth2,
pages, shop,
},
@@ -83,6 +83,7 @@ impl Hooks for App {
Box::new(initializers::view_engine::ViewEngineInitializer),
Box::new(initializers::admin_seeder::AdminSeeder),
Box::new(initializers::shipping_seeder::ShippingSeeder),
+ Box::new(initializers::payment_seeder::PaymentSeeder),
Box::new(initializers::currency_seeder::CurrencySeeder),
Box::new(initializers::oauth2::OAuth2StoreInitializer),
Box::new(initializers::oauth2_session::OAuth2SessionInitializer),
@@ -111,6 +112,7 @@ impl Hooks for App {
.add_route(admin_discount_profiles::routes())
.add_route(admin_categories::routes())
.add_route(admin_orders::routes())
+ .add_route(admin_payments::routes())
.add_route(admin_customers::routes())
.add_route(admin_shipping::routes())
.add_route(admin_currencies::routes())
diff --git a/src/controllers/account.rs b/src/controllers/account.rs
index 4e72be5..e04e34b 100644
--- a/src/controllers/account.rs
+++ b/src/controllers/account.rs
@@ -335,6 +335,7 @@ async fn order_detail_page(
.all(&ctx.db)
.await?;
+ let (bank_iban, bank_account_name) = settings::bank_details(&ctx).await?;
format::view(
&v,
"account/order_detail.html",
@@ -347,8 +348,8 @@ async fn order_detail_page(
"customer_avatar": user.avatar_id,
"order": order_view::detail(
&order,
- settings::get(&ctx, "bank_iban").unwrap_or(""),
- settings::get(&ctx, "bank_account_name").unwrap_or(""),
+ &bank_iban,
+ &bank_account_name,
),
"items": order_view::items(&items),
"lang": current_lang(&jar),
diff --git a/src/controllers/admin_orders.rs b/src/controllers/admin_orders.rs
index fa6d01d..9a9402a 100644
--- a/src/controllers/admin_orders.rs
+++ b/src/controllers/admin_orders.rs
@@ -93,6 +93,7 @@ async fn render_show(
.await?;
let carrier = order_carrier(ctx, &order).await?;
+ let (bank_iban, bank_account_name) = settings::bank_details(ctx).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();
@@ -103,8 +104,8 @@ async fn render_show(
json!({
"order": view::detail(
&order,
- settings::get(ctx, "bank_iban").unwrap_or(""),
- settings::get(ctx, "bank_account_name").unwrap_or(""),
+ &bank_iban,
+ &bank_account_name,
),
"items": view::items(&items),
"statuses": ORDER_STATUSES,
diff --git a/src/controllers/admin_payments.rs b/src/controllers/admin_payments.rs
new file mode 100644
index 0000000..954cbb7
--- /dev/null
+++ b/src/controllers/admin_payments.rs
@@ -0,0 +1,112 @@
+//! Admin management for checkout payment methods and bank-transfer details.
+
+use axum_extra::extract::cookie::CookieJar;
+use loco_rs::prelude::*;
+use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set};
+use serde::Deserialize;
+use serde_json::json;
+
+use crate::{
+ controllers::i18n::current_lang,
+ models::{payment_methods, shop_settings},
+ shared::guard,
+};
+
+#[derive(Debug, Deserialize)]
+struct PaymentMethodForm {
+ enabled: Option
,
+}
+
+#[derive(Debug, Deserialize)]
+struct BankSettingsForm {
+ bank_account_name: String,
+ bank_iban: String,
+}
+
+fn is_checked(value: &Option) -> bool {
+ matches!(value.as_deref(), Some("on" | "true" | "1"))
+}
+
+fn trimmed(value: &str) -> Option {
+ let value = value.trim();
+ (!value.is_empty()).then(|| value.to_string())
+}
+
+#[debug_handler]
+async fn index(
+ auth: auth::JWT,
+ jar: CookieJar,
+ ViewEngine(v): ViewEngine,
+ State(ctx): State,
+) -> Result {
+ guard::current_admin(auth, &ctx).await?;
+ let methods = payment_methods::Entity::find()
+ .order_by_asc(payment_methods::Column::Position)
+ .all(&ctx.db)
+ .await?;
+ let rows: Vec = methods
+ .iter()
+ .map(|m| {
+ json!({
+ "id": m.id,
+ "code": m.code,
+ "label_key": m.label_key(),
+ "enabled": m.enabled,
+ })
+ })
+ .collect();
+ let bank_account_name = shop_settings::Entity::get(&ctx.db, "bank_account_name")
+ .await?
+ .unwrap_or_default();
+ let bank_iban = shop_settings::Entity::get(&ctx.db, "bank_iban")
+ .await?
+ .unwrap_or_default();
+
+ format::view(
+ &v,
+ "admin/payments/index.html",
+ json!({
+ "methods": rows,
+ "bank_account_name": bank_account_name,
+ "bank_iban": bank_iban,
+ "lang": current_lang(&jar),
+ }),
+ )
+}
+
+#[debug_handler]
+async fn update_method(
+ auth: auth::JWT,
+ Path(id): Path,
+ State(ctx): State,
+ Form(form): Form,
+) -> Result {
+ guard::current_admin(auth, &ctx).await?;
+ let method = payment_methods::Entity::find_by_id(id)
+ .one(&ctx.db)
+ .await?
+ .ok_or_else(|| Error::NotFound)?;
+ let mut active = method.into_active_model();
+ active.enabled = Set(is_checked(&form.enabled));
+ active.update(&ctx.db).await?;
+ format::redirect("/admin/payments")
+}
+
+#[debug_handler]
+async fn update_bank(
+ auth: auth::JWT,
+ State(ctx): State,
+ Form(form): Form,
+) -> Result {
+ guard::current_admin(auth, &ctx).await?;
+ shop_settings::Entity::set(&ctx.db, "bank_account_name", trimmed(&form.bank_account_name)).await?;
+ shop_settings::Entity::set(&ctx.db, "bank_iban", trimmed(&form.bank_iban)).await?;
+ format::redirect("/admin/payments")
+}
+
+pub fn routes() -> Routes {
+ Routes::new()
+ .add("/admin/payments", get(index))
+ .add("/admin/payments/methods/{id}", post(update_method))
+ .add("/admin/payments/bank", post(update_bank))
+}
diff --git a/src/controllers/checkout.rs b/src/controllers/checkout.rs
index 52895f1..142a7c1 100644
--- a/src/controllers/checkout.rs
+++ b/src/controllers/checkout.rs
@@ -14,7 +14,7 @@ use crate::{
mailers::auth::AuthMailer,
models::{
customer_profiles::{self, ProfileFields},
- order_items, orders, shipping_methods,
+ order_items, orders, payment_methods, shipping_methods,
users::{self, normalize_account_type},
},
controllers::i18n::current_lang,
@@ -22,8 +22,6 @@ use crate::{
views::checkout as view,
};
-const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"];
-
#[derive(Debug, Deserialize)]
struct CheckoutForm {
email: String,
@@ -76,6 +74,10 @@ async fn enabled_shipping_methods(ctx: &AppContext) -> Result Result> {
+ Ok(payment_methods::Entity::enabled(&ctx.db).await?)
+}
+
#[debug_handler]
async fn checkout_page(
jar: CookieJar,
@@ -102,6 +104,16 @@ async fn checkout_page(
})
})
.collect();
+ let payments: Vec = enabled_payment_methods(&ctx)
+ .await?
+ .iter()
+ .map(|m| {
+ json!({
+ "code": m.code,
+ "label_key": m.label_key(),
+ })
+ })
+ .collect();
// Prefill the form for a logged-in customer: contact name/email come from
// the user account, the address/phone from their saved profile (if any).
@@ -130,6 +142,7 @@ async fn checkout_page(
"subtotal": format_price(subtotal),
"subtotal_cents": subtotal,
"shipping_methods": methods,
+ "payment_methods": payments,
"packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""),
"logged_in_admin": is_admin,
"logged_in_customer": is_customer,
@@ -239,7 +252,7 @@ async fn place_order(
(None, None, None, None)
};
- if !PAYMENT_METHODS.contains(&form.payment_method.as_str()) {
+ if payment_methods::Entity::find_enabled(&ctx.db, &form.payment_method).await?.is_none() {
return Err(Error::BadRequest("invalid payment method".to_string()));
}
@@ -392,14 +405,15 @@ async fn order_confirmation(
let c = guard::chrome(&ctx, &jar).await;
let account_created = params.contains_key("account_created");
+ let (bank_iban, bank_account_name) = settings::bank_details(&ctx).await?;
format::view(
&v,
"shop/order_confirmed.html",
json!({
"order": view::detail(
&order,
- settings::get(&ctx, "bank_iban").unwrap_or(""),
- settings::get(&ctx, "bank_account_name").unwrap_or(""),
+ &bank_iban,
+ &bank_account_name,
),
"items": view::items(&items),
"logged_in_admin": c.logged_in_admin,
diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs
index fb88f6e..e73ead5 100644
--- a/src/controllers/mod.rs
+++ b/src/controllers/mod.rs
@@ -9,6 +9,7 @@ pub mod admin_dashboard;
pub mod admin_discount_profiles;
pub mod admin_form;
pub mod admin_orders;
+pub mod admin_payments;
pub mod admin_products;
pub mod admin_shipping;
pub mod cart;
diff --git a/src/initializers/mod.rs b/src/initializers/mod.rs
index 4163c46..a3fb5d4 100644
--- a/src/initializers/mod.rs
+++ b/src/initializers/mod.rs
@@ -2,5 +2,6 @@ pub mod admin_seeder;
pub mod currency_seeder;
pub mod oauth2;
pub mod oauth2_session;
+pub mod payment_seeder;
pub mod shipping_seeder;
pub mod view_engine;
diff --git a/src/initializers/payment_seeder.rs b/src/initializers/payment_seeder.rs
new file mode 100644
index 0000000..4f10112
--- /dev/null
+++ b/src/initializers/payment_seeder.rs
@@ -0,0 +1,73 @@
+//! Ensures built-in payment methods and editable bank-transfer settings exist.
+//!
+//! Payment method enabled flags and bank account details are admin-managed in the
+//! database. We seed missing rows only, so admin changes persist across restarts.
+
+use async_trait::async_trait;
+use loco_rs::prelude::*;
+use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
+
+use crate::{
+ models::{payment_methods, shop_settings},
+ shared::settings,
+};
+
+/// `(code, name, enabled, position)`
+const METHODS: [(&str, &str, bool, i32); 2] = [
+ (payment_methods::COD, "Cash on delivery", true, 0),
+ (payment_methods::BANK_TRANSFER, "Bank transfer", true, 1),
+];
+
+pub struct PaymentSeeder;
+
+#[async_trait]
+impl Initializer for PaymentSeeder {
+ fn name(&self) -> String {
+ "payment-seeder".to_string()
+ }
+
+ async fn before_run(&self, ctx: &AppContext) -> Result<()> {
+ for (code, name, enabled, position) in METHODS {
+ let exists = payment_methods::Entity::find()
+ .filter(payment_methods::Column::Code.eq(code))
+ .count(&ctx.db)
+ .await?
+ > 0;
+ if exists {
+ continue;
+ }
+ payment_methods::ActiveModel {
+ code: Set(code.to_string()),
+ name: Set(name.to_string()),
+ enabled: Set(enabled),
+ position: Set(position),
+ ..Default::default()
+ }
+ .insert(&ctx.db)
+ .await?;
+ tracing::info!(payment = code, "seeded built-in payment method");
+ }
+
+ seed_setting(ctx, "bank_iban").await?;
+ seed_setting(ctx, "bank_account_name").await
+ }
+}
+
+async fn seed_setting(ctx: &AppContext, key: &str) -> Result<()> {
+ let exists = shop_settings::Entity::find()
+ .filter(shop_settings::Column::Key.eq(key))
+ .count(&ctx.db)
+ .await?
+ > 0;
+ if exists {
+ return Ok(());
+ }
+ shop_settings::ActiveModel {
+ key: Set(key.to_string()),
+ value: Set(settings::get(ctx, key).map(str::to_string)),
+ ..Default::default()
+ }
+ .insert(&ctx.db)
+ .await?;
+ Ok(())
+}
diff --git a/src/models/_entities/mod.rs b/src/models/_entities/mod.rs
index ba3e0b9..d0fd0fa 100644
--- a/src/models/_entities/mod.rs
+++ b/src/models/_entities/mod.rs
@@ -15,10 +15,12 @@ pub mod discount_profiles;
pub mod o_auth2_sessions;
pub mod order_items;
pub mod orders;
+pub mod payment_methods;
pub mod product_images;
pub mod product_product_tags;
pub mod product_tags;
pub mod product_variants;
pub mod products;
pub mod shipping_methods;
+pub mod shop_settings;
pub mod users;
diff --git a/src/models/_entities/payment_methods.rs b/src/models/_entities/payment_methods.rs
new file mode 100644
index 0000000..deb0c48
--- /dev/null
+++ b/src/models/_entities/payment_methods.rs
@@ -0,0 +1,21 @@
+//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
+
+use sea_orm::entity::prelude::*;
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
+#[sea_orm(table_name = "payment_methods")]
+pub struct Model {
+ pub created_at: DateTimeWithTimeZone,
+ pub updated_at: DateTimeWithTimeZone,
+ #[sea_orm(primary_key)]
+ pub id: i32,
+ #[sea_orm(unique)]
+ pub code: String,
+ pub name: String,
+ pub enabled: bool,
+ pub position: i32,
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {}
diff --git a/src/models/_entities/prelude.rs b/src/models/_entities/prelude.rs
index ee1e63a..911c273 100644
--- a/src/models/_entities/prelude.rs
+++ b/src/models/_entities/prelude.rs
@@ -13,10 +13,12 @@ pub use super::discount_profiles::Entity as DiscountProfiles;
pub use super::o_auth2_sessions::Entity as OAuth2Sessions;
pub use super::order_items::Entity as OrderItems;
pub use super::orders::Entity as Orders;
+pub use super::payment_methods::Entity as PaymentMethods;
pub use super::product_images::Entity as ProductImages;
pub use super::product_product_tags::Entity as ProductProductTags;
pub use super::product_tags::Entity as ProductTags;
pub use super::product_variants::Entity as ProductVariants;
pub use super::products::Entity as Products;
pub use super::shipping_methods::Entity as ShippingMethods;
+pub use super::shop_settings::Entity as ShopSettings;
pub use super::users::Entity as Users;
diff --git a/src/models/_entities/shop_settings.rs b/src/models/_entities/shop_settings.rs
new file mode 100644
index 0000000..a86fed3
--- /dev/null
+++ b/src/models/_entities/shop_settings.rs
@@ -0,0 +1,20 @@
+//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
+
+use sea_orm::entity::prelude::*;
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
+#[sea_orm(table_name = "shop_settings")]
+pub struct Model {
+ pub created_at: DateTimeWithTimeZone,
+ pub updated_at: DateTimeWithTimeZone,
+ #[sea_orm(primary_key)]
+ pub id: i32,
+ #[sea_orm(unique)]
+ pub key: String,
+ #[sea_orm(column_type = "Text", nullable)]
+ pub value: Option,
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {}
diff --git a/src/models/mod.rs b/src/models/mod.rs
index e9b6721..8bbc1aa 100644
--- a/src/models/mod.rs
+++ b/src/models/mod.rs
@@ -19,10 +19,12 @@ pub mod customer_profiles;
pub mod o_auth2_sessions;
pub mod order_items;
pub mod orders;
+pub mod payment_methods;
pub mod product_images;
pub mod product_product_tags;
pub mod product_tags;
pub mod products;
pub mod shipping_methods;
+pub mod shop_settings;
pub mod users;
pub mod product_variants;
diff --git a/src/models/payment_methods.rs b/src/models/payment_methods.rs
new file mode 100644
index 0000000..e13e5e3
--- /dev/null
+++ b/src/models/payment_methods.rs
@@ -0,0 +1,54 @@
+use sea_orm::entity::prelude::*;
+use sea_orm::{ActiveValue, ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
+
+pub use crate::models::_entities::payment_methods::{ActiveModel, Column, Entity, Model};
+pub type PaymentMethods = Entity;
+
+pub const COD: &str = "cod";
+pub const BANK_TRANSFER: &str = "bank_transfer";
+
+#[async_trait::async_trait]
+impl ActiveModelBehavior for ActiveModel {
+ async fn before_save(self, _db: &C, insert: bool) -> std::result::Result
+ where
+ C: ConnectionTrait,
+ {
+ if !insert && self.updated_at.is_unchanged() {
+ let mut this = self;
+ this.updated_at = ActiveValue::set(chrono::Utc::now().into());
+ Ok(this)
+ } else {
+ Ok(self)
+ }
+ }
+}
+
+impl Entity {
+ pub async fn enabled(db: &C) -> Result, DbErr> {
+ Entity::find()
+ .filter(Column::Enabled.eq(true))
+ .order_by_asc(Column::Position)
+ .all(db)
+ .await
+ }
+
+ pub async fn find_enabled(db: &C, code: &str) -> Result