From 996358be877f55c88a877670f778c544a769822d Mon Sep 17 00:00:00 2001 From: Priec Date: Thu, 18 Jun 2026 21:27:15 +0200 Subject: [PATCH] company or personal --- assets/i18n/en/main.ftl | 9 ++++ assets/i18n/sk/main.ftl | 9 ++++ assets/views/account/profile.html | 43 ++++++++++++++++++- assets/views/admin/orders/show.html | 9 ++++ assets/views/shop/checkout.html | 39 +++++++++++++++++ migration/src/lib.rs | 2 + .../src/m20260618_000003_account_type.rs | 39 +++++++++++++++++ src/controllers/account.rs | 28 ++++++++++++ src/controllers/checkout.rs | 35 +++++++++++++++ src/models/_entities/customer_profiles.rs | 5 +++ src/models/_entities/orders.rs | 5 +++ src/models/customer_profiles.rs | 13 +++++- src/models/orders.rs | 10 +++++ src/views/checkout.rs | 5 +++ 14 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 migration/src/m20260618_000003_account_type.rs diff --git a/assets/i18n/en/main.ftl b/assets/i18n/en/main.ftl index 591e9c0..eb46d62 100644 --- a/assets/i18n/en/main.ftl +++ b/assets/i18n/en/main.ftl @@ -258,6 +258,15 @@ country-pl = Poland country-hu = Hungary checkout-note = Order note checkout-save-profile = Save this address to my profile +account-type = Account type +account-personal = Individual +account-company = Company +account-company-details = Company details +company-name = Company name +company-ico = Company ID (IČO) +company-dic = Tax ID (DIČ) +company-icdph = VAT ID (IČ DPH) +field-optional = optional checkout-place-order = Place order checkout-summary = Order summary profile-title = My profile diff --git a/assets/i18n/sk/main.ftl b/assets/i18n/sk/main.ftl index 88c5c83..d4271ec 100644 --- a/assets/i18n/sk/main.ftl +++ b/assets/i18n/sk/main.ftl @@ -258,6 +258,15 @@ country-pl = Poľsko country-hu = Maďarsko checkout-note = Poznámka k objednávke checkout-save-profile = Uložiť túto adresu do môjho profilu +account-type = Typ účtu +account-personal = Súkromná osoba +account-company = Firma +account-company-details = Firemné údaje +company-name = Názov firmy +company-ico = IČO +company-dic = DIČ +company-icdph = IČ DPH +field-optional = nepovinné checkout-place-order = Odoslať objednávku checkout-summary = Súhrn objednávky profile-title = Môj profil diff --git a/assets/views/account/profile.html b/assets/views/account/profile.html index 0e10064..5c54e32 100644 --- a/assets/views/account/profile.html +++ b/assets/views/account/profile.html @@ -14,8 +14,47 @@ {% endif %} -
- + + +
+ {{ t(key="account-type", lang=lang | default(value='sk')) }} +
+ + +
+
+ + +
+ {{ t(key="account-company-details", lang=lang | default(value='sk')) }} +
+ + {{ ui::input(name="company_name", id="company_name", value=company_name | default(value=''), autocomplete="organization") }} +
+
+
+ + {{ ui::input(name="company_id", id="company_id", value=company_id | default(value='')) }} +
+
+ + {{ ui::input(name="tax_id", id="tax_id", value=tax_id | default(value='')) }} +
+
+ + {{ ui::input(name="vat_id", id="vat_id", value=vat_id | default(value='')) }} +
+
+
+ +
{{ t(key="checkout-contact", lang=lang | default(value='sk')) }}
diff --git a/assets/views/admin/orders/show.html b/assets/views/admin/orders/show.html index afcb213..b826b44 100644 --- a/assets/views/admin/orders/show.html +++ b/assets/views/admin/orders/show.html @@ -52,6 +52,15 @@

{{ order.email }}

{% if order.phone %}

{{ order.phone }}

{% endif %}
+ {% if order.account_type == "company" %} +
+

{{ t(key="account-company-details", lang=lang | default(value='sk')) }}

+

{{ order.company_name }}

+

{{ t(key="company-ico", lang=lang | default(value='sk')) }}: {{ order.company_id }}

+

{{ t(key="company-dic", lang=lang | default(value='sk')) }}: {{ order.tax_id }}

+ {% if order.vat_id %}

{{ t(key="company-icdph", lang=lang | default(value='sk')) }}: {{ order.vat_id }}

{% endif %} +
+ {% endif %}

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

{{ order.address }}
{{ order.zip }} {{ order.city }}
{{ order.country }}

diff --git a/assets/views/shop/checkout.html b/assets/views/shop/checkout.html index 8ccc3f2..ab3f162 100644 --- a/assets/views/shop/checkout.html +++ b/assets/views/shop/checkout.html @@ -11,6 +11,7 @@
+ +
+ {{ t(key="account-type", lang=lang | default(value='sk')) }} +
+ + +
+
+ + +
+ {{ t(key="account-company-details", lang=lang | default(value='sk')) }} +
+ + {{ ui::input(name="company_name", id="company_name", value=prefill_company_name | default(value=''), autocomplete="organization") }} +
+
+
+ + {{ ui::input(name="company_id", id="company_id", value=prefill_company_id | default(value='')) }} +
+
+ + {{ ui::input(name="tax_id", id="tax_id", value=prefill_tax_id | default(value='')) }} +
+
+ + {{ ui::input(name="vat_id", id="vat_id", value=prefill_vat_id | default(value='')) }} +
+
+
+
{{ t(key="checkout-contact", lang=lang | default(value='sk')) }} diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 2594493..9401149 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -32,6 +32,7 @@ mod m20260617_000002_add_shipment_to_orders; mod m20260617_000003_add_phone_to_orders; mod m20260618_000001_o_auth2_sessions; mod m20260618_000002_customer_profiles; +mod m20260618_000003_account_type; pub struct Migrator; #[async_trait::async_trait] @@ -68,6 +69,7 @@ impl MigratorTrait for Migrator { Box::new(m20260617_000003_add_phone_to_orders::Migration), Box::new(m20260618_000001_o_auth2_sessions::Migration), Box::new(m20260618_000002_customer_profiles::Migration), + Box::new(m20260618_000003_account_type::Migration), // inject-above (do not remove this comment) ] } diff --git a/migration/src/m20260618_000003_account_type.rs b/migration/src/m20260618_000003_account_type.rs new file mode 100644 index 0000000..fde922f --- /dev/null +++ b/migration/src/m20260618_000003_account_type.rs @@ -0,0 +1,39 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +// Personal vs company purchasing. `account_type` is "personal" or "company"; +// the company_* columns hold the Slovak invoicing identifiers (IČO, DIČ and the +// optional VAT id IČ DPH) and are only filled for company accounts/orders. +const COMPANY_COLUMNS: [&str; 4] = ["company_name", "company_id", "tax_id", "vat_id"]; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> { + for table in ["customer_profiles", "orders"] { + add_column( + m, + table, + "account_type", + ColType::StringWithDefault("personal".to_string()), + ) + .await?; + for col in COMPANY_COLUMNS { + add_column(m, table, col, ColType::StringNull).await?; + } + } + Ok(()) + } + + async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> { + for table in ["customer_profiles", "orders"] { + remove_column(m, table, "account_type").await?; + for col in COMPANY_COLUMNS { + remove_column(m, table, col).await?; + } + } + Ok(()) + } +} diff --git a/src/controllers/account.rs b/src/controllers/account.rs index 9f6ea6e..f10f6d6 100644 --- a/src/controllers/account.rs +++ b/src/controllers/account.rs @@ -16,6 +16,11 @@ use crate::{ #[derive(Debug, Deserialize)] struct ProfileForm { + account_type: Option, + company_name: Option, + company_id: Option, + tax_id: Option, + vat_id: Option, phone_prefix: Option, phone: Option, address: Option, @@ -28,9 +33,27 @@ fn trimmed(value: Option<&str>) -> Option { value.map(str::trim).filter(|v| !v.is_empty()).map(String::from) } +/// Normalize an account type to one of the two known values, defaulting to +/// "personal" for anything unexpected. +pub fn normalize_account_type(value: Option<&str>) -> String { + match value.map(str::trim) { + Some("company") => "company".to_string(), + _ => "personal".to_string(), + } +} + impl From for ProfileFields { fn from(form: ProfileForm) -> Self { + let is_company = normalize_account_type(form.account_type.as_deref()) == "company"; + // Company identifiers are only stored for company accounts, so switching + // back to personal clears stale data. + let company = |v: Option<&str>| if is_company { trimmed(v) } else { None }; Self { + account_type: normalize_account_type(form.account_type.as_deref()), + company_name: company(form.company_name.as_deref()), + company_id: company(form.company_id.as_deref()), + tax_id: company(form.tax_id.as_deref()), + vat_id: company(form.vat_id.as_deref()), phone_prefix: trimmed(form.phone_prefix.as_deref()), phone: trimmed(form.phone.as_deref()), address: trimmed(form.address.as_deref()), @@ -61,6 +84,11 @@ fn profile_view( "saved": saved, "name": name, "email": email, + "account_type": profile.map_or("personal", |p| p.account_type.as_str()), + "company_name": profile.and_then(|p| p.company_name.clone()), + "company_id": profile.and_then(|p| p.company_id.clone()), + "tax_id": profile.and_then(|p| p.tax_id.clone()), + "vat_id": profile.and_then(|p| p.vat_id.clone()), "phone_prefix": profile.and_then(|p| p.phone_prefix.clone()), "phone": profile.and_then(|p| p.phone.clone()), "address": profile.and_then(|p| p.address.clone()), diff --git a/src/controllers/checkout.rs b/src/controllers/checkout.rs index 37a627e..21b4e4b 100644 --- a/src/controllers/checkout.rs +++ b/src/controllers/checkout.rs @@ -9,6 +9,7 @@ use serde_json::json; use time::Duration as TimeDuration; use crate::{ + controllers::account::normalize_account_type, controllers::cart::{resolve_cart, CART_COOKIE}, models::{customer_profiles::{self, ProfileFields}, order_items, orders, shipping_methods}, controllers::i18n::current_lang, @@ -24,6 +25,11 @@ struct CheckoutForm { phone_prefix: String, phone: String, customer_name: String, + account_type: Option, + company_name: Option, + company_id: Option, + tax_id: Option, + vat_id: Option, address: String, city: String, zip: String, @@ -115,6 +121,11 @@ async fn checkout_page( "logged_in_customer": is_customer, "prefill_email": user.as_ref().filter(|_| is_customer).map(|u| u.email.clone()), "prefill_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()), + "prefill_account_type": profile.as_ref().map_or("personal", |x| x.account_type.as_str()), + "prefill_company_name": p(|x| x.company_name.clone()), + "prefill_company_id": p(|x| x.company_id.clone()), + "prefill_tax_id": p(|x| x.tax_id.clone()), + "prefill_vat_id": p(|x| x.vat_id.clone()), "prefill_phone_prefix": p(|x| x.phone_prefix.clone()), "prefill_phone": p(|x| x.phone.clone()), "prefill_address": p(|x| x.address.clone()), @@ -158,6 +169,20 @@ async fn place_order( let zip = require(&form.zip, "zip")?; let country = require(&form.country, "country")?; + // Company purchases must carry the invoicing identifiers (IČO + DIČ + // required, IČ DPH optional). Personal orders carry none. + let account_type = normalize_account_type(form.account_type.as_deref()); + let (company_name, company_id, tax_id, vat_id) = if account_type == "company" { + ( + Some(require(form.company_name.as_deref().unwrap_or(""), "company name")?), + Some(require(form.company_id.as_deref().unwrap_or(""), "IČO")?), + Some(require(form.tax_id.as_deref().unwrap_or(""), "DIČ")?), + form.vat_id.as_deref().and_then(trimmed), + ) + } else { + (None, None, None, None) + }; + if !PAYMENT_METHODS.contains(&form.payment_method.as_str()) { return Err(Error::BadRequest("invalid payment method".to_string())); } @@ -190,6 +215,11 @@ async fn place_order( if let Some(user) = guard::current_user(&ctx, &jar).await { if !guard::is_admin(&ctx, &user) { let fields = ProfileFields { + account_type: account_type.clone(), + company_name: company_name.clone(), + company_id: company_id.clone(), + tax_id: tax_id.clone(), + vat_id: vat_id.clone(), phone_prefix: trimmed(&form.phone_prefix), phone: Some(number.clone()), address: Some(address.clone()), @@ -211,6 +241,11 @@ async fn place_order( email, phone, customer_name: Some(customer_name), + account_type, + company_name, + company_id, + tax_id, + vat_id, address: Some(address), city: Some(city), zip: Some(zip), diff --git a/src/models/_entities/customer_profiles.rs b/src/models/_entities/customer_profiles.rs index 9ea4f85..9a0952f 100644 --- a/src/models/_entities/customer_profiles.rs +++ b/src/models/_entities/customer_profiles.rs @@ -13,6 +13,11 @@ pub struct Model { pub id: i32, #[sea_orm(unique)] pub user_id: i32, + pub account_type: String, + pub company_name: Option, + pub company_id: Option, + pub tax_id: Option, + pub vat_id: Option, pub phone_prefix: Option, pub phone: Option, pub address: Option, diff --git a/src/models/_entities/orders.rs b/src/models/_entities/orders.rs index fe113d2..b0b4e0b 100644 --- a/src/models/_entities/orders.rs +++ b/src/models/_entities/orders.rs @@ -18,6 +18,11 @@ pub struct Model { pub status: String, pub total_cents: i64, pub currency: String, + pub account_type: String, + pub company_name: Option, + pub company_id: Option, + pub tax_id: Option, + pub vat_id: Option, pub address: Option, pub city: Option, pub zip: Option, diff --git a/src/models/customer_profiles.rs b/src/models/customer_profiles.rs index 20a8419..e30ca1e 100644 --- a/src/models/customer_profiles.rs +++ b/src/models/customer_profiles.rs @@ -9,9 +9,15 @@ use sea_orm::{ActiveValue, IntoActiveModel, QueryFilter, TryIntoModel}; pub type CustomerProfiles = Entity; /// The editable profile fields, shared by the profile page and the checkout -/// "save my address" path. +/// "save my address" path. `account_type` is "personal" or "company"; the +/// `company_*` fields are only meaningful for company accounts. #[derive(Debug, Default, Clone)] pub struct ProfileFields { + pub account_type: String, + pub company_name: Option, + pub company_id: Option, + pub tax_id: Option, + pub vat_id: Option, pub phone_prefix: Option, pub phone: Option, pub address: Option, @@ -53,6 +59,11 @@ impl Model { ..Default::default() }, }; + active.account_type = ActiveValue::set(fields.account_type); + active.company_name = ActiveValue::set(fields.company_name); + active.company_id = ActiveValue::set(fields.company_id); + active.tax_id = ActiveValue::set(fields.tax_id); + active.vat_id = ActiveValue::set(fields.vat_id); active.phone_prefix = ActiveValue::set(fields.phone_prefix); active.phone = ActiveValue::set(fields.phone); active.address = ActiveValue::set(fields.address); diff --git a/src/models/orders.rs b/src/models/orders.rs index a49768a..5cd9e0d 100644 --- a/src/models/orders.rs +++ b/src/models/orders.rs @@ -14,6 +14,11 @@ pub struct Checkout { pub email: String, pub phone: String, pub customer_name: Option, + pub account_type: String, + pub company_name: Option, + pub company_id: Option, + pub tax_id: Option, + pub vat_id: Option, pub address: Option, pub city: Option, pub zip: Option, @@ -70,6 +75,11 @@ pub async fn place(ctx: &AppContext, items: &[(i32, i32)], details: Checkout) -> status: Set("pending".to_string()), total_cents: Set(subtotal + details.method.price_cents), currency: Set(currency), + account_type: Set(details.account_type), + company_name: Set(details.company_name), + company_id: Set(details.company_id), + tax_id: Set(details.tax_id), + vat_id: Set(details.vat_id), address: Set(details.address), city: Set(details.city), zip: Set(details.zip), diff --git a/src/views/checkout.rs b/src/views/checkout.rs index 015d7ee..22dc1bc 100644 --- a/src/views/checkout.rs +++ b/src/views/checkout.rs @@ -30,6 +30,11 @@ pub fn detail(order: &orders::Model, bank_iban: &str, bank_account_name: &str) - "email": order.email, "phone": order.phone, "customer_name": order.customer_name, + "account_type": order.account_type, + "company_name": order.company_name, + "company_id": order.company_id, + "tax_id": order.tax_id, + "vat_id": order.vat_id, "status": order.status, "subtotal": format_price(order.total_cents - order.shipping_cents), "shipping": format_price(order.shipping_cents),