Skip to main content

monzo/endpoints/
transactions.rs

1//! Retrieve and manipulate transactions
2
3use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use crate::endpoints::utils::empty_string_as_none;
9
10mod list;
11pub use list::Request as List;
12mod get;
13pub use get::Request as Get;
14
15/// A Monzo transaction
16#[allow(clippy::struct_excessive_bools)]
17#[non_exhaustive]
18#[derive(Deserialize, Debug, Clone, PartialEq)]
19pub struct Transaction {
20    /// The unique ID of the account associated with the transaction
21    pub account_id: String,
22
23    /// The amount of the transaction, in the smallest unit of currency (ie.
24    /// 'pence' or 'cents')
25    pub amount: i64,
26
27    /// Whether the transaction is pending, or complete
28    pub amount_is_pending: bool,
29
30    /// Whether the transaction can be added to a tab
31    pub can_add_to_tab: bool,
32
33    /// Whether the transaction can be excluded from breakdown
34    pub can_be_excluded_from_breakdown: bool,
35
36    /// Whether the transaction can be made into a recurring subscription
37    pub can_be_made_subscription: bool,
38
39    /// Whether the transaction can be split
40    pub can_split_the_bill: bool,
41
42    /// The transaction category
43    pub category: String,
44
45    /// The timestamp when the transaction was created
46    pub created: DateTime<Utc>,
47
48    /// The three-letter currency string for the transaction
49    pub currency: String,
50
51    /// The transaction description
52    pub description: String,
53
54    /// The unique transaction ID
55    pub id: String,
56
57    /// Whether transaction is included in spending
58    pub include_in_spending: bool,
59
60    /// This can be either None, the merchant ID, or an object containing the
61    /// merchant details
62    pub merchant: Option<MerchantInfo>,
63
64    /// Any custom metadata which has been added to the transaction
65    pub metadata: HashMap<String, String>,
66
67    /// User-added transaction notes
68    pub notes: String,
69
70    /// If the transaction was declined, this enum will encode the reason
71    pub decline_reason: Option<DeclineReason>,
72
73    /// Top-ups to an account are represented as transactions with a positive
74    /// amount and `is_load` = true. Other transactions such as refunds,
75    /// reversals or chargebacks may have a positive amount but `is_load` =
76    /// false
77    pub is_load: bool,
78
79    /// The timestamp at which the transaction was settled
80    ///
81    /// This is `None` if the transaction is authorised, but not yet complete.
82    #[serde(deserialize_with = "empty_string_as_none")]
83    pub settled: Option<DateTime<Utc>>,
84}
85
86/// The set of reasons for which a monzo transaction may be declined
87#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
88#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
89#[non_exhaustive]
90pub enum DeclineReason {
91    /// Not enough funds in account to complete transaction
92    InsufficientFunds,
93
94    /// Monzo card is not active
95    CardInactive,
96
97    /// The monzo card has been blocked
98    CardBlocked,
99
100    /// The monzo card has been closed
101    CardClosed,
102
103    /// Incorrect CVC code used
104    InvalidCvc,
105
106    /// Strong Customer Authentication blocking 'not present' transaction
107    ScaNotAuthenticatedCardNotPresent,
108
109    /// Requires SCA
110    StrongCustomerAuthenticationRequired,
111
112    /// Transaction declined by the cardholder
113    AuthenticationRejectedByCardholder,
114
115    /// Transaction declined because verifcation failed
116    AuthenticationVerificationFailed,
117
118    /// Transaction declined by the decisioning engine
119    DecisioningEngineHardDecline,
120
121    /// All other errors
122    Other,
123}
124
125/// Merchant information which might be returned in transactions data.
126///
127/// An id or a struct may be returned depending on whether the 'expand merchant'
128/// flag is set in the transactions request.
129#[derive(Deserialize, Debug, PartialEq, Clone)]
130#[serde(untagged)]
131pub enum MerchantInfo {
132    /// A unique ID associated with a merchant
133    Id(String),
134
135    /// Extra merchant information which may optionally be requested
136    Details(Box<Merchant>),
137}
138
139/// Merchant details
140#[derive(Deserialize, Debug, PartialEq, Clone)]
141#[allow(missing_docs)]
142pub struct Merchant {
143    pub address: Address,
144    pub created: DateTime<Utc>,
145    pub group_id: String,
146    pub id: String,
147    pub logo: String,
148    pub emoji: String,
149    pub name: String,
150    pub category: String,
151}
152
153/// Address details
154#[derive(Deserialize, Debug, PartialEq, Clone)]
155#[allow(missing_docs)]
156pub struct Address {
157    pub address: String,
158    pub city: String,
159    pub country: String,
160    pub latitude: f32,
161    pub longitude: f32,
162    pub postcode: String,
163    pub region: String,
164}
165
166#[derive(Serialize, Default, Debug)]
167struct Pagination {
168    #[serde(skip_serializing_if = "Option::is_none")]
169    limit: Option<u16>,
170
171    #[serde(skip_serializing_if = "Option::is_none")]
172    since: Option<Since>,
173
174    #[serde(skip_serializing_if = "Option::is_none")]
175    before: Option<DateTime<Utc>>,
176}
177
178/// The 'since' parameter of a pagination request can be either a timestamp or
179/// an object id
180#[derive(Debug, Serialize, Clone)]
181#[serde(untagged)]
182pub enum Since {
183    /// A timestamp
184    Timestamp(DateTime<Utc>),
185
186    /// An id of an object
187    ObjectId(String),
188}
189
190#[cfg(test)]
191mod tests {
192    #![allow(clippy::too_many_lines, clippy::non_ascii_literal)]
193    use super::Transaction;
194
195    #[test]
196    fn deserialise_expanded_transaction() {
197        let raw = r##"
198        {
199          "id": "tx_0000A1aBC2Dbc34Ede5fEH",
200          "created": "2021-06-29T13:10:09.992Z",
201          "description": "Online Subscription",
202          "amount": -5000,
203          "fees": {},
204          "currency": "GBP",
205          "merchant": {
206            "id": "merch_000000abcABCDEFGdHIeJ0",
207            "group_id": "grp_000000abc1ABde2fChDE34",
208            "created": "2016-01-08T00:20:13.969Z",
209            "name": "Online Service",
210            "logo": "https://mondo-logo-cache.appspot.com/twitter/ServiceUk/?size=large",
211            "emoji": "💻",
212            "category": "entertainment",
213            "online": true,
214            "atm": false,
215            "address": {
216              "short_formatted": "Somewhere in the world",
217              "formatted": "world",
218              "address": "",
219              "city": "",
220              "region": "",
221              "country": "GLO",
222              "postcode": "",
223              "latitude": 50.99999999999999,
224              "longitude": 5.111111111111111,
225              "zoom_level": 5,
226              "approximate": true
227            },
228            "updated": "2021-06-17T14:21:38.608Z",
229            "metadata": {
230              "created_for_merchant": "merch_000000abcABCDEFGdHIeJ0",
231              "created_for_transaction": "tx_0000A1aBC2Dbc34Ede5fEH",
232              "provider": "user",
233              "provider_id": "",
234              "suggested_tags": "#subscription #personal",
235              "twitter_id": "ServiceUk",
236              "website": "service.co.uk"
237            },
238            "disable_feedback": false
239          },
240          "notes": "Subscription to online service",
241          "metadata": {
242            "ledger_insertion_id": "entryset_0000A2bBcDEF3HdIJK4LMe",
243            "mastercard_approval_type": "full",
244            "mastercard_auth_message_id": "mcauthmsg_0000A2bBcDEF3HdIJK4LMe",
245            "mastercard_card_id": "mccard_0000A2bBcDEF3HdIJK4LMe",
246            "mastercard_lifecycle_id": "mclifecycle_0000A2bBcDEF3HdIJK4LMe",
247            "mcc": "1234"
248          },
249          "labels": null,
250          "attachments": null,
251          "international": null,
252          "category": "bills",
253          "categories": {
254            "bills": -5000
255          },
256          "is_load": false,
257          "settled": "2021-06-30T00:46:44.233Z",
258          "local_amount": -3900,
259          "local_currency": "GBP",
260          "updated": "2021-06-30T00:46:44.589Z",
261          "account_id": "acc_99999aAbBc0DEFH1I2JdKL",
262          "user_id": "user_000000abcABCDEFGdHIeJ",
263          "counterparty": {},
264          "scheme": "mastercard",
265          "dedupe_id": "mclifecycle",
266          "originator": false,
267          "include_in_spending": true,
268          "can_be_excluded_from_breakdown": true,
269          "can_be_made_subscription": true,
270          "can_split_the_bill": true,
271          "can_add_to_tab": true,
272          "amount_is_pending": false,
273          "atm_fees_detailed": null
274        }
275        "##;
276
277        serde_json::from_str::<Transaction>(raw).expect("couldn't decode Transaction from json");
278    }
279
280    #[test]
281    fn deserialise_declined_transaction() {
282        let raw = r##"
283        {
284          "id": "tx_0000A1aBC2Dbc34Ede5fEH",
285          "created": "2021-06-29T13:10:09.992Z",
286          "description": "Online Subscription",
287          "amount": -5000,
288          "fees": {},
289          "currency": "GBP",
290          "merchant": {
291            "id": "merch_000000abcABCDEFGdHIeJ0",
292            "group_id": "grp_000000abc1ABde2fChDE34",
293            "created": "2016-01-08T00:20:13.969Z",
294            "name": "Online Service",
295            "logo": "https://mondo-logo-cache.appspot.com/twitter/ServiceUk/?size=large",
296            "emoji": "💻",
297            "category": "entertainment",
298            "online": true,
299            "atm": false,
300            "address": {
301              "short_formatted": "Somewhere in the world",
302              "formatted": "world",
303              "address": "",
304              "city": "",
305              "region": "",
306              "country": "GLO",
307              "postcode": "",
308              "latitude": 50.99999999999999,
309              "longitude": 5.111111111111111,
310              "zoom_level": 5,
311              "approximate": true
312            },
313            "updated": "2021-06-17T14:21:38.608Z",
314            "metadata": {
315              "created_for_merchant": "merch_000000abcABCDEFGdHIeJ0",
316              "created_for_transaction": "tx_0000A1aBC2Dbc34Ede5fEH",
317              "provider": "user",
318              "provider_id": "",
319              "suggested_tags": "#subscription #personal",
320              "twitter_id": "ServiceUk",
321              "website": "service.co.uk"
322            },
323            "disable_feedback": false
324          },
325          "notes": "Subscription to online service",
326          "metadata": {
327            "ledger_insertion_id": "entryset_0000A2bBcDEF3HdIJK4LMe",
328            "mastercard_approval_type": "full",
329            "mastercard_auth_message_id": "mcauthmsg_0000A2bBcDEF3HdIJK4LMe",
330            "mastercard_card_id": "mccard_0000A2bBcDEF3HdIJK4LMe",
331            "mastercard_lifecycle_id": "mclifecycle_0000A2bBcDEF3HdIJK4LMe",
332            "mcc": "1234"
333          },
334          "labels": null,
335          "attachments": null,
336          "international": null,
337          "category": "bills",
338          "categories": {
339            "bills": -5000
340          },
341          "is_load": false,
342          "settled": "2021-06-30T00:46:44.233Z",
343          "decline_reason": "SCA_NOT_AUTHENTICATED_CARD_NOT_PRESENT",
344          "local_amount": -3900,
345          "local_currency": "GBP",
346          "updated": "2021-06-30T00:46:44.589Z",
347          "account_id": "acc_99999aAbBc0DEFH1I2JdKL",
348          "user_id": "user_000000abcABCDEFGdHIeJ",
349          "counterparty": {},
350          "scheme": "mastercard",
351          "dedupe_id": "mclifecycle",
352          "originator": false,
353          "include_in_spending": true,
354          "can_be_excluded_from_breakdown": true,
355          "can_be_made_subscription": true,
356          "can_split_the_bill": true,
357          "can_add_to_tab": true,
358          "amount_is_pending": false,
359          "atm_fees_detailed": null
360        }
361        "##;
362
363        serde_json::from_str::<Transaction>(raw).expect("couldn't decode Transaction from json");
364
365        let new = raw.replace(
366            "SCA_NOT_AUTHENTICATED_CARD_NOT_PRESENT",
367            "AUTHENTICATION_REJECTED_BY_CARDHOLDER",
368        );
369        serde_json::from_str::<Transaction>(&new).expect("couldn't decode Transaction from json");
370
371        let new = raw.replace(
372            "AUTHENTICATION_REJECTED_BY_CARDHOLDER",
373            "AUTHENTICATION_VERIFICATION_FAILED",
374        );
375        serde_json::from_str::<Transaction>(&new).expect("couldn't decode Transaction from json");
376
377        let new = raw.replace(
378            "AUTHENTICATION_VERIFICATION_FAILED",
379            "DECISIONING_ENGINE_HARD_DECLINE",
380        );
381        serde_json::from_str::<Transaction>(&new).expect("couldn't decode Transaction from json");
382
383        let new = raw.replace("DECISIONING_ENGINE_HARD_DECLINE", "CARD_CLOSED");
384        serde_json::from_str::<Transaction>(&new).expect("couldn't decode Transaction from json");
385    }
386
387    #[test]
388    // Tests for null merchant
389    fn deserialise_topup_transaction() {
390        let raw = r#"
391        {
392          "id": "tx_0000A1aBC2Dbc34Ede5fEF",
393          "created": "2021-07-01T00:21:30.935Z",
394          "description": "USER",
395          "amount": 2000,
396          "fees": {},
397          "currency": "GBP",
398          "merchant": null,
399          "notes": "USER",
400          "metadata": {
401            "faster_payment": "true",
402            "fps_fpid": "FP123456789123456789123456789123456",
403            "fps_payment_id": "FP123456789123456789123456789123456",
404            "insertion": "entryset_0000A1aBC2Dbc34Ede5fEF",
405            "notes": "USER",
406            "trn": "FP12345678912345"
407          },
408          "labels": null,
409          "attachments": null,
410          "international": null,
411          "category": "general",
412          "categories": null,
413          "is_load": false,
414          "settled": "2021-07-01T06:00:00Z",
415          "local_amount": 2000,
416          "local_currency": "GBP",
417          "updated": "2021-07-01T00:21:31.022Z",
418          "account_id": "acc_99999aAbBc0DEFH1I2JdKL",
419          "user_id": "",
420          "counterparty": {
421            "account_number": "12345678",
422            "name": "John Smith",
423            "sort_code": "987654",
424            "user_id": "anonuser_1234567a89b123456cd7e8"
425          },
426          "scheme": "payport_faster_pajments",
427          "dedupe_id": "com.monzo.fps:1234:FP123456789123456789123456789123456:INBOUND",
428          "originator": false,
429          "include_in_spending": false,
430          "can_be_excluded_from_breakdown": false,
431          "can_be_made_subscription": false,
432          "can_split_the_bill": false,
433          "can_add_to_tab": false,
434          "amount_is_pending": false,
435          "atm_fees_detailed": null
436        }
437        "#;
438
439        serde_json::from_str::<Transaction>(raw).expect("couldn't decode Transaction from json");
440    }
441
442    #[test]
443    fn deserialise_list() {
444        use serde::Deserialize;
445        #[derive(Deserialize)]
446        struct Response {
447            #[allow(dead_code)]
448            transactions: Vec<Transaction>,
449        }
450
451        let raw = r##"
452        {
453          "transactions": [
454            {
455              "id": "tx_0000A1aBC2Dbc34Ede5fEH",
456              "created": "2021-06-29T13:10:09.992Z",
457              "description": "Online Subscription",
458              "amount": -3900,
459              "fees": {},
460              "currency": "GBP",
461              "merchant": {
462                "id": "merch_000000abcABCDEFGdHIeJ0",
463                "group_id": "grp_000000abc1ABde2fChDE34",
464                "created": "2016-01-08T00:20:13.969Z",
465                "name": "Online Service",
466                "logo": "https://mondo-logo-cache.appspot.com/twitter/ServiceUk/?size=large",
467                "emoji": "💻",
468                "category": "entertainment",
469                "online": true,
470                "atm": false,
471                "address": {
472                  "short_formatted": "Somewhere in the world",
473                  "formatted": "world",
474                  "address": "",
475                  "city": "",
476                  "region": "",
477                  "country": "GLO",
478                  "postcode": "",
479                  "latitude": 50.99999999999999,
480                  "longitude": 5.111111111111111,
481                  "zoom_level": 5,
482                  "approximate": true
483                },
484                "updated": "2021-06-17T14:21:38.608Z",
485                "metadata": {
486                  "created_for_merchant": "merch_000000abcABCDEFGdHIeJ0",
487                  "created_for_transaction": "tx_0000A1aBC2Dbc34Ede5fEH",
488                  "provider": "user",
489                  "provider_id": "",
490                  "suggested_tags": "#subscription #personal",
491                  "twitter_id": "ServiceUk",
492                  "website": "service.co.uk"
493                },
494                "disable_feedback": false
495              },
496              "notes": "Subscription to online service",
497              "metadata": {
498                "ledger_insertion_id": "entryset_0000A2bBcDEF3HdIJK4LMe",
499                "mastercard_approval_type": "full",
500                "mastercard_auth_message_id": "mcauthmsg_0000A2bBcDEF3HdIJK4LMe",
501                "mastercard_card_id": "mccard_0000A2bBcDEF3HdIJK4LMe",
502                "mastercard_lifecycle_id": "mclifecycle_0000A2bBcDEF3HdIJK4LMe",
503                "mcc": "1234"
504              },
505              "labels": null,
506              "attachments": null,
507              "international": null,
508              "category": "bills",
509              "categories": {
510                "bills": -3900
511              },
512              "is_load": false,
513              "settled": "2021-06-30T00:46:44.233Z",
514              "local_amount": -3900,
515              "local_currency": "GBP",
516              "updated": "2021-06-30T00:46:44.589Z",
517              "account_id": "acc_99999aAbBc0DEFH1I2JdKL",
518              "user_id": "user_000000abcABCDEFGdHIeJ",
519              "counterparty": {},
520              "scheme": "mastercard",
521              "dedupe_id": "mclifecycle",
522              "originator": false,
523              "include_in_spending": true,
524              "can_be_excluded_from_breakdown": true,
525              "can_be_made_subscription": true,
526              "can_split_the_bill": true,
527              "can_add_to_tab": true,
528              "amount_is_pending": false,
529              "atm_fees_detailed": null
530            },
531            {
532              "id": "tx_0000A1aBC2Dbc34Ede5fEF",
533              "created": "2021-07-01T00:21:30.935Z",
534              "description": "USER",
535              "amount": 2000,
536              "fees": {},
537              "currency": "GBP",
538              "merchant": null,
539              "notes": "USER",
540              "metadata": {
541                "faster_payment": "true",
542                "fps_fpid": "FP123456789123456789123456789123456",
543                "fps_payment_id": "FP123456789123456789123456789123456",
544                "insertion": "entryset_0000A1aBC2Dbc34Ede5fEF",
545                "notes": "USER",
546                "trn": "FP12345678912345"
547              },
548              "labels": null,
549              "attachments": null,
550              "international": null,
551              "category": "general",
552              "categories": null,
553              "is_load": false,
554              "settled": "2021-07-01T06:00:00Z",
555              "local_amount": 2000,
556              "local_currency": "GBP",
557              "updated": "2021-07-01T00:21:31.022Z",
558              "account_id": "acc_99999aAbBc0DEFH1I2JdKL",
559              "user_id": "",
560              "counterparty": {
561                "account_number": "12345678",
562                "name": "John Smith",
563                "sort_code": "987654",
564                "user_id": "anonuser_1234567a89b123456cd7e8"
565              },
566              "scheme": "payport_faster_pajments",
567              "dedupe_id": "com.monzo.fps:1234:FP123456789123456789123456789123456:INBOUND",
568              "originator": false,
569              "include_in_spending": false,
570              "can_be_excluded_from_breakdown": false,
571              "can_be_made_subscription": false,
572              "can_split_the_bill": false,
573              "can_add_to_tab": false,
574              "amount_is_pending": false,
575              "atm_fees_detailed": null
576            }
577          ]
578        }
579        "##;
580
581        serde_json::from_str::<Response>(raw).expect("couldn't decode Transaction from json");
582    }
583}