1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
//! B2B company (account) domain models
//!
//! A company is a B2B customer account that groups contacts, shipping
//! addresses, product price overrides, sales orders and invoices. It is
//! distinct from an end-consumer `Customer`.
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_primitives::{CompanyAddressId, CompanyId, ContactId, CurrencyCode, ProductId};
use strum::{Display, EnumString};
/// Lifecycle status of a company account.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[non_exhaustive]
pub enum CompanyStatus {
/// Active account.
#[default]
Active,
/// Inactive / archived account.
Inactive,
}
/// A B2B company / account.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Company {
/// Unique company ID.
pub id: CompanyId,
/// Company name.
pub name: String,
/// External reference / customer number.
pub reference: Option<String>,
/// Primary email.
pub email: Option<String>,
/// Primary phone.
pub phone: Option<String>,
/// Default currency for this company.
pub currency: CurrencyCode,
/// Net payment terms in days (e.g. 30 for Net-30).
pub payment_terms_days: Option<i32>,
/// Lifecycle status.
pub status: CompanyStatus,
/// Free-form tags.
pub tags: Vec<String>,
/// Arbitrary metadata / custom fields.
pub metadata: serde_json::Value,
/// When the company was created.
pub created_at: DateTime<Utc>,
/// When the company was last updated.
pub updated_at: DateTime<Utc>,
}
/// A shipping address belonging to a company.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompanyShippingAddress {
/// Unique address ID.
pub id: CompanyAddressId,
/// Owning company.
pub company_id: CompanyId,
/// Optional label (e.g. "HQ", "Warehouse").
pub label: Option<String>,
/// Recipient / attention name.
pub name: Option<String>,
/// Street line 1.
pub line1: String,
/// Street line 2.
pub line2: Option<String>,
/// City.
pub city: String,
/// State / province / region.
pub region: Option<String>,
/// Postal / zip code.
pub postal_code: Option<String>,
/// ISO 3166-1 alpha-2 country code.
pub country: String,
/// Whether this is the company's default shipping address.
pub is_default: bool,
/// When the address was created.
pub created_at: DateTime<Utc>,
/// When the address was last updated.
pub updated_at: DateTime<Utc>,
}
/// A contact associated with one or more companies.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Contact {
/// Unique contact ID.
pub id: ContactId,
/// First name (required).
pub first_name: String,
/// Last name.
pub last_name: Option<String>,
/// Email.
pub email: Option<String>,
/// Phone.
pub phone: Option<String>,
/// Job title / role.
pub title: Option<String>,
/// Companies this contact belongs to.
pub company_ids: Vec<CompanyId>,
/// Whether the contact has B2B portal access (a portal password is set).
pub portal_enabled: bool,
/// Whether the contact is active. Soft-deleting sets this to `false`.
pub is_active: bool,
/// When the contact was created.
pub created_at: DateTime<Utc>,
/// When the contact was last updated.
pub updated_at: DateTime<Utc>,
}
/// A company-specific price override for a product.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompanyPriceOverride {
/// Owning company.
pub company_id: CompanyId,
/// Product the override applies to.
pub product_id: ProductId,
/// Overridden unit price.
pub price: Decimal,
/// Currency for the override.
pub currency: CurrencyCode,
/// When the override was created.
pub created_at: DateTime<Utc>,
/// When the override was last updated.
pub updated_at: DateTime<Utc>,
}
/// Input for creating a company.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateCompany {
/// Company name.
pub name: String,
/// External reference.
pub reference: Option<String>,
/// Email.
pub email: Option<String>,
/// Phone.
pub phone: Option<String>,
/// Currency (defaults to account base currency when omitted).
pub currency: Option<CurrencyCode>,
/// Net payment terms in days.
pub payment_terms_days: Option<i32>,
/// Tags.
#[serde(default)]
pub tags: Vec<String>,
/// Metadata / custom fields.
#[serde(default)]
pub metadata: serde_json::Value,
}
/// Input for updating a company. All fields optional (partial update).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdateCompany {
/// Updated name.
pub name: Option<String>,
/// Updated reference.
pub reference: Option<String>,
/// Updated email.
pub email: Option<String>,
/// Updated phone.
pub phone: Option<String>,
/// Updated currency.
pub currency: Option<CurrencyCode>,
/// Updated payment terms.
pub payment_terms_days: Option<i32>,
/// Updated status.
pub status: Option<CompanyStatus>,
/// Updated tags.
pub tags: Option<Vec<String>>,
/// Updated metadata.
pub metadata: Option<serde_json::Value>,
}
/// Input for creating a contact. Requires `first_name` and at least one company.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateContact {
/// First name (required).
pub first_name: String,
/// Last name.
pub last_name: Option<String>,
/// Email.
pub email: Option<String>,
/// Phone.
pub phone: Option<String>,
/// Title.
pub title: Option<String>,
/// Companies this contact belongs to (at least one required).
pub company_ids: Vec<CompanyId>,
}
/// Filter for listing companies.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CompanyFilter {
/// Filter by status.
pub status: Option<CompanyStatus>,
/// Free-text search over name / reference / email.
pub search: Option<String>,
/// Maximum results.
pub limit: Option<u32>,
/// Offset for pagination.
pub offset: Option<u32>,
}
impl Contact {
/// Full display name, joining first and last name when present.
#[must_use]
pub fn display_name(&self) -> String {
match &self.last_name {
Some(last) if !last.is_empty() => format!("{} {}", self.first_name, last),
_ => self.first_name.clone(),
}
}
/// Returns `true` if the contact is linked to the given company.
#[must_use]
pub fn belongs_to(&self, company_id: CompanyId) -> bool {
self.company_ids.contains(&company_id)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_contact(first: &str, last: Option<&str>, companies: Vec<CompanyId>) -> Contact {
Contact {
id: ContactId::new(),
first_name: first.to_string(),
last_name: last.map(String::from),
email: None,
phone: None,
title: None,
company_ids: companies,
portal_enabled: false,
is_active: true,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn display_name_joins_first_and_last() {
let c = make_contact("Ada", Some("Lovelace"), vec![]);
assert_eq!(c.display_name(), "Ada Lovelace");
}
#[test]
fn display_name_first_only() {
assert_eq!(make_contact("Ada", None, vec![]).display_name(), "Ada");
assert_eq!(make_contact("Ada", Some(""), vec![]).display_name(), "Ada");
}
#[test]
fn belongs_to_checks_membership() {
let cid = CompanyId::new();
let other = CompanyId::new();
let c = make_contact("Ada", None, vec![cid]);
assert!(c.belongs_to(cid));
assert!(!c.belongs_to(other));
}
#[test]
fn company_status_roundtrip() {
for s in [CompanyStatus::Active, CompanyStatus::Inactive] {
let parsed: CompanyStatus = s.to_string().parse().unwrap();
assert_eq!(parsed, s);
}
}
#[test]
fn company_status_default_is_active() {
assert_eq!(CompanyStatus::default(), CompanyStatus::Active);
}
}