admin panel have more control over payment now

This commit is contained in:
Priec
2026-06-27 14:27:37 +02:00
parent e8d8aafd97
commit d1f9838890
23 changed files with 497 additions and 17 deletions

View File

@@ -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())

View File

@@ -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),

View File

@@ -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,

View File

@@ -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<String>,
}
#[derive(Debug, Deserialize)]
struct BankSettingsForm {
bank_account_name: String,
bank_iban: String,
}
fn is_checked(value: &Option<String>) -> bool {
matches!(value.as_deref(), Some("on" | "true" | "1"))
}
fn trimmed(value: &str) -> Option<String> {
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
}
#[debug_handler]
async fn index(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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<serde_json::Value> = 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<i32>,
State(ctx): State<AppContext>,
Form(form): Form<PaymentMethodForm>,
) -> Result<Response> {
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<AppContext>,
Form(form): Form<BankSettingsForm>,
) -> Result<Response> {
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))
}

View File

@@ -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<Vec<shipping_metho
.await?)
}
async fn enabled_payment_methods(ctx: &AppContext) -> Result<Vec<payment_methods::Model>> {
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<serde_json::Value> = 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,

View File

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

View File

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

View File

@@ -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(())
}

View File

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

View File

@@ -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 {}

View File

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

View File

@@ -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<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

View File

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

View File

@@ -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<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
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<C: ConnectionTrait>(db: &C) -> Result<Vec<Model>, DbErr> {
Entity::find()
.filter(Column::Enabled.eq(true))
.order_by_asc(Column::Position)
.all(db)
.await
}
pub async fn find_enabled<C: ConnectionTrait>(db: &C, code: &str) -> Result<Option<Model>, DbErr> {
Entity::find()
.filter(Column::Code.eq(code))
.filter(Column::Enabled.eq(true))
.one(db)
.await
}
}
impl Model {
pub fn label_key(&self) -> &'static str {
match self.code.as_str() {
COD => "payment-cod",
BANK_TRANSFER => "payment-bank",
_ => "payment-custom",
}
}
}
impl ActiveModel {}

View File

@@ -0,0 +1,47 @@
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, TryIntoModel};
pub use crate::models::_entities::shop_settings::{ActiveModel, Column, Entity, Model};
pub type ShopSettings = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
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 get<C: ConnectionTrait>(db: &C, key: &str) -> Result<Option<String>, DbErr> {
Ok(Entity::find()
.filter(Column::Key.eq(key))
.one(db)
.await?
.and_then(|setting| setting.value))
}
pub async fn set<C: ConnectionTrait>(db: &C, key: &str, value: Option<String>) -> Result<Model, DbErr> {
let mut active = match Entity::find()
.filter(Column::Key.eq(key))
.one(db)
.await?
{
Some(existing) => existing.into_active_model(),
None => ActiveModel {
key: ActiveValue::set(key.to_string()),
..Default::default()
},
};
active.value = ActiveValue::set(value);
active.save(db).await?.try_into_model()
}
}

View File

@@ -2,6 +2,8 @@
use loco_rs::prelude::*;
use crate::models::shop_settings;
/// Look up a string-valued `settings.<key>` entry, returning `None` if config
/// has no settings map, the key is missing, or the value is not a string.
pub fn get<'a>(ctx: &'a AppContext, key: &str) -> Option<&'a str> {
@@ -11,3 +13,20 @@ pub fn get<'a>(ctx: &'a AppContext, key: &str) -> Option<&'a str> {
.and_then(|settings| settings.get(key))
.and_then(|value| value.as_str())
}
/// Look up an admin-editable setting in the database, falling back to config when
/// the row is missing. Empty DB values are returned as-is so admins can clear a
/// setting deliberately.
pub async fn get_editable(ctx: &AppContext, key: &str) -> Result<String> {
Ok(match shop_settings::Entity::get(&ctx.db, key).await? {
Some(value) => value,
None => get(ctx, key).unwrap_or("").to_string(),
})
}
pub async fn bank_details(ctx: &AppContext) -> Result<(String, String)> {
Ok((
get_editable(ctx, "bank_iban").await?,
get_editable(ctx, "bank_account_name").await?,
))
}