Skip to main content

rust_ynab/ynab/
payee.rs

1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4use crate::PlanId;
5use crate::ynab::client::Client;
6use crate::ynab::common::{NO_PARAMS, ServerKnowledge};
7use crate::ynab::errors::Error;
8
9#[derive(Debug, Deserialize, Serialize)]
10struct PayeesDataEnvelope {
11    data: PayeesData,
12}
13
14#[derive(Debug, Deserialize, Serialize)]
15struct PayeesData {
16    payees: Vec<Payee>,
17    server_knowledge: ServerKnowledge,
18}
19
20#[derive(Debug, Deserialize, Serialize)]
21struct PayeeDataEnvelope {
22    data: PayeeData,
23}
24
25#[derive(Debug, Deserialize, Serialize)]
26struct PayeeData {
27    payee: Payee,
28    server_knowledge: ServerKnowledge,
29}
30
31/// A payee for a plan.
32#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
33pub struct Payee {
34    pub id: Uuid,
35    pub name: String,
36    pub transfer_account_id: Option<Uuid>,
37    pub deleted: bool,
38}
39
40#[derive(Debug, Deserialize, Serialize)]
41struct PayeeLocationDataEnvelope {
42    data: PayeeLocationData,
43}
44
45#[derive(Debug, Deserialize, Serialize)]
46struct PayeeLocationData {
47    payee_location: PayeeLocation,
48}
49
50#[derive(Debug, Deserialize, Serialize)]
51struct PayeeLocationsDataEnvelope {
52    data: PayeeLocationsData,
53}
54
55#[derive(Debug, Deserialize, Serialize)]
56struct PayeeLocationsData {
57    payee_locations: Vec<PayeeLocation>,
58}
59
60/// A GPS location stored when a transaction is entered on a mobile device. Locations will not be
61/// available for all payees.
62#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
63pub struct PayeeLocation {
64    pub id: Uuid,
65    pub payee_id: Uuid,
66    pub latitude: String,
67    pub longitude: String,
68    pub deleted: bool,
69}
70
71#[derive(Debug)]
72pub struct GetPayeesBuilder<'a> {
73    client: &'a Client,
74    plan_id: PlanId,
75    last_knowledge_of_server: Option<ServerKnowledge>,
76}
77
78impl<'a> GetPayeesBuilder<'a> {
79    pub fn with_server_knowledge(mut self, sk: ServerKnowledge) -> Self {
80        self.last_knowledge_of_server = Some(sk);
81        self
82    }
83
84    /// Sends the request. Returns payees and server knowledge for use in subsequent delta requests.
85    pub async fn send(self) -> Result<(Vec<Payee>, ServerKnowledge), Error> {
86        let params: Option<&[(&str, &str)]> = if let Some(sk) = self.last_knowledge_of_server {
87            Some(&[("last_knowledge_of_server", &sk.to_string())])
88        } else {
89            None
90        };
91        let result: PayeesDataEnvelope = self
92            .client
93            .get(&format!("plans/{}/payees", self.plan_id), params)
94            .await?;
95        Ok((result.data.payees, result.data.server_knowledge))
96    }
97}
98
99impl Client {
100    /// Returns a builder for fetching all payees. Chain `.with_server_knowledge()` for a delta request.
101    pub fn get_payees(&self, plan_id: PlanId) -> GetPayeesBuilder<'_> {
102        GetPayeesBuilder {
103            client: self,
104            plan_id,
105            last_knowledge_of_server: None,
106        }
107    }
108    /// Returns a single payee.
109    pub async fn get_payee(
110        &self,
111        plan_id: PlanId,
112        payee_id: Uuid,
113    ) -> Result<(Payee, ServerKnowledge), Error> {
114        let result: PayeeDataEnvelope = self
115            .get(&format!("plans/{}/payees/{}", plan_id, payee_id), NO_PARAMS)
116            .await?;
117        Ok((result.data.payee, result.data.server_knowledge))
118    }
119
120    /// Returns all payee locations.
121    pub async fn get_payee_locations(&self, plan_id: PlanId) -> Result<Vec<PayeeLocation>, Error> {
122        let result: PayeeLocationsDataEnvelope = self
123            .get(&format!("plans/{}/payee_locations", plan_id), NO_PARAMS)
124            .await?;
125        Ok(result.data.payee_locations)
126    }
127
128    /// Returns all payee locations for a specified payee.
129    pub async fn get_payee_locations_by_payee(
130        &self,
131        plan_id: PlanId,
132        payee_id: Uuid,
133    ) -> Result<Vec<PayeeLocation>, Error> {
134        let result: PayeeLocationsDataEnvelope = self
135            .get(
136                &format!("plans/{}/payees/{}/payee_locations", plan_id, payee_id),
137                NO_PARAMS,
138            )
139            .await?;
140        Ok(result.data.payee_locations)
141    }
142
143    /// Returns a single payee location.
144    pub async fn get_payee_location(
145        &self,
146        plan_id: PlanId,
147        location_id: Uuid,
148    ) -> Result<PayeeLocation, Error> {
149        let result: PayeeLocationDataEnvelope = self
150            .get(
151                &format!("plans/{}/payee_locations/{}", plan_id, location_id),
152                NO_PARAMS,
153            )
154            .await?;
155        Ok(result.data.payee_location)
156    }
157}
158
159/// Request body for creating a new payee. Name is required and must not exceed 500
160/// characters.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
162pub struct PostPayee {
163    pub name: String,
164}
165#[derive(Debug, Serialize)]
166struct PostPayeeWrapper {
167    payee: PostPayee,
168}
169
170/// Request body for updating an existing payee. All fields are optional; omitted fields are
171/// not changed.
172#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
173pub struct SavePayee {
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub name: Option<String>,
176}
177#[derive(Debug, Serialize)]
178struct PatchPayeeWrapper {
179    payee: SavePayee,
180}
181
182impl Client {
183    /// Creates a new payee. Returns the created payee and server knowledge for delta requests.
184    pub async fn create_payee(
185        &self,
186        plan_id: PlanId,
187        payee: PostPayee,
188    ) -> Result<(Payee, ServerKnowledge), Error> {
189        let result: PayeeDataEnvelope = self
190            .post(
191                &format!("plans/{}/payees", plan_id),
192                PostPayeeWrapper { payee },
193            )
194            .await?;
195        Ok((result.data.payee, result.data.server_knowledge))
196    }
197
198    /// Updates an existing payee. Returns the updated payee and server knowledge for delta
199    /// requests.
200    pub async fn update_payee(
201        &self,
202        plan_id: PlanId,
203        payee_id: Uuid,
204        payee: SavePayee,
205    ) -> Result<(Payee, ServerKnowledge), Error> {
206        let result: PayeeDataEnvelope = self
207            .patch(
208                &format!("plans/{}/payees/{}", plan_id, payee_id),
209                PatchPayeeWrapper { payee },
210            )
211            .await?;
212        Ok((result.data.payee, result.data.server_knowledge))
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::ynab::testutil::{
220        TEST_ID_1, TEST_ID_3, TEST_ID_4, error_body, new_test_client, payee_fixture,
221        payee_location_fixture,
222    };
223    use serde_json::json;
224    use uuid::uuid;
225    use wiremock::matchers::{method, path};
226    use wiremock::{Mock, ResponseTemplate};
227
228    fn payees_list_fixture() -> serde_json::Value {
229        json!({ "data": { "payees": [payee_fixture()], "server_knowledge": 3 } })
230    }
231
232    fn payee_single_fixture() -> serde_json::Value {
233        json!({ "data": { "payee": payee_fixture(), "server_knowledge": 3 } })
234    }
235
236    fn payee_locations_list_fixture() -> serde_json::Value {
237        json!({ "data": { "payee_locations": [payee_location_fixture()] } })
238    }
239
240    fn payee_location_single_fixture() -> serde_json::Value {
241        json!({ "data": { "payee_location": payee_location_fixture() } })
242    }
243
244    #[tokio::test]
245    async fn get_payees_returns_payees() {
246        let (client, server) = new_test_client().await;
247        Mock::given(method("GET"))
248            .and(path(format!("/plans/{}/payees", TEST_ID_1)))
249            .respond_with(ResponseTemplate::new(200).set_body_json(payees_list_fixture()))
250            .expect(1)
251            .mount(&server)
252            .await;
253        let (payees, sk) = client
254            .get_payees(PlanId::Id(uuid!(TEST_ID_1)))
255            .send()
256            .await
257            .unwrap();
258        assert_eq!(payees.len(), 1);
259        assert_eq!(payees[0].id.to_string(), TEST_ID_3);
260        assert_eq!(sk, 3);
261    }
262
263    #[tokio::test]
264    async fn get_payee_returns_payee() {
265        let (client, server) = new_test_client().await;
266        Mock::given(method("GET"))
267            .and(path(format!("/plans/{}/payees/{}", TEST_ID_1, TEST_ID_3)))
268            .respond_with(ResponseTemplate::new(200).set_body_json(payee_single_fixture()))
269            .expect(1)
270            .mount(&server)
271            .await;
272        let (payee, _) = client
273            .get_payee(PlanId::Id(uuid!(TEST_ID_1)), uuid!(TEST_ID_3))
274            .await
275            .unwrap();
276        assert_eq!(payee.id.to_string(), TEST_ID_3);
277        assert_eq!(payee.name, "Amazon");
278    }
279
280    #[tokio::test]
281    async fn get_payee_locations_returns_locations() {
282        let (client, server) = new_test_client().await;
283        Mock::given(method("GET"))
284            .and(path(format!("/plans/{}/payee_locations", TEST_ID_1)))
285            .respond_with(ResponseTemplate::new(200).set_body_json(payee_locations_list_fixture()))
286            .expect(1)
287            .mount(&server)
288            .await;
289        let locations = client
290            .get_payee_locations(PlanId::Id(uuid!(TEST_ID_1)))
291            .await
292            .unwrap();
293        assert_eq!(locations.len(), 1);
294        assert_eq!(locations[0].id.to_string(), TEST_ID_4);
295    }
296
297    #[tokio::test]
298    async fn get_payee_locations_by_payee_returns_locations() {
299        let (client, server) = new_test_client().await;
300        Mock::given(method("GET"))
301            .and(path(format!(
302                "/plans/{}/payees/{}/payee_locations",
303                TEST_ID_1, TEST_ID_3
304            )))
305            .respond_with(ResponseTemplate::new(200).set_body_json(payee_locations_list_fixture()))
306            .expect(1)
307            .mount(&server)
308            .await;
309        let locations = client
310            .get_payee_locations_by_payee(PlanId::Id(uuid!(TEST_ID_1)), uuid!(TEST_ID_3))
311            .await
312            .unwrap();
313        assert_eq!(locations.len(), 1);
314        assert_eq!(locations[0].payee_id.to_string(), TEST_ID_3);
315    }
316
317    #[tokio::test]
318    async fn get_payee_location_returns_location() {
319        let (client, server) = new_test_client().await;
320        Mock::given(method("GET"))
321            .and(path(format!(
322                "/plans/{}/payee_locations/{}",
323                TEST_ID_1, TEST_ID_4
324            )))
325            .respond_with(ResponseTemplate::new(200).set_body_json(payee_location_single_fixture()))
326            .expect(1)
327            .mount(&server)
328            .await;
329        let location = client
330            .get_payee_location(PlanId::Id(uuid!(TEST_ID_1)), uuid!(TEST_ID_4))
331            .await
332            .unwrap();
333        assert_eq!(location.id.to_string(), TEST_ID_4);
334        assert_eq!(location.latitude, "37.7749");
335    }
336
337    #[tokio::test]
338    async fn create_payee_succeeds() {
339        let (client, server) = new_test_client().await;
340        Mock::given(method("POST"))
341            .and(path(format!("/plans/{}/payees", TEST_ID_1)))
342            .respond_with(ResponseTemplate::new(201).set_body_json(payee_single_fixture()))
343            .expect(1)
344            .mount(&server)
345            .await;
346        let (payee, sk) = client
347            .create_payee(
348                PlanId::Id(uuid!(TEST_ID_1)),
349                PostPayee {
350                    name: "Amazon".to_string(),
351                },
352            )
353            .await
354            .unwrap();
355        assert_eq!(payee.id.to_string(), TEST_ID_3);
356        assert_eq!(sk, 3);
357    }
358
359    #[tokio::test]
360    async fn update_payee_succeeds() {
361        let (client, server) = new_test_client().await;
362        Mock::given(method("PATCH"))
363            .and(path(format!("/plans/{}/payees/{}", TEST_ID_1, TEST_ID_3)))
364            .respond_with(ResponseTemplate::new(200).set_body_json(payee_single_fixture()))
365            .expect(1)
366            .mount(&server)
367            .await;
368        let (payee, _) = client
369            .update_payee(
370                PlanId::Id(uuid!(TEST_ID_1)),
371                uuid!(TEST_ID_3),
372                SavePayee {
373                    name: Some("Amazon Updated".to_string()),
374                },
375            )
376            .await
377            .unwrap();
378        assert_eq!(payee.id.to_string(), TEST_ID_3);
379    }
380
381    #[tokio::test]
382    async fn get_payee_returns_not_found() {
383        let (client, server) = new_test_client().await;
384        Mock::given(method("GET"))
385            .and(path(format!("/plans/{}/payees/{}", TEST_ID_1, TEST_ID_3)))
386            .respond_with(ResponseTemplate::new(404).set_body_json(error_body(
387                "404",
388                "not_found",
389                "Payee not found",
390            )))
391            .mount(&server)
392            .await;
393        let err = client
394            .get_payee(PlanId::Id(uuid!(TEST_ID_1)), uuid!(TEST_ID_3))
395            .await
396            .unwrap_err();
397        assert!(matches!(err, Error::NotFound(_)));
398    }
399}