Passed
Push — main ( d0a955...ec822a )
by Jochen
04:45
created

_build_incoming_line_items()   A

Complexity

Conditions 2

Size

Total Lines 20
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 3.1852

Importance

Changes 0
Metric Value
cc 2
eloc 17
nop 1
dl 0
loc 20
ccs 2
cts 6
cp 0.3333
crap 3.1852
rs 9.55
c 0
b 0
f 0
1
"""
2
byceps.services.shop.order.order_domain_service
3
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4
5
:Copyright: 2014-2024 Jochen Kupperschmidt
6
:License: Revised BSD (see `LICENSE` file for details)
7
"""
8
9 1
from collections.abc import Iterator
10
from datetime import datetime, timedelta
11 1
12
from moneyed import Currency, Money
13 1
14
from byceps.events.base import EventUser
15 1
from byceps.events.shop import ShopOrderCanceledEvent, ShopOrderPaidEvent
16 1
from byceps.services.shop.cart.models import Cart, CartItem
17 1
from byceps.services.shop.shop.models import ShopID
18 1
from byceps.services.shop.storefront.models import StorefrontID
19
from byceps.services.user.models.user import User
20 1
from byceps.util.result import Err, Ok, Result
21 1
from byceps.util.uuid import generate_uuid7
22 1
23 1
from .errors import (
24
    CartEmpty,
25
    OrderAlreadyCanceledError,
26 1
    OrderAlreadyMarkedAsPaidError,
27
)
28
from .models.checkout import IncomingLineItem, IncomingOrder
29 1
from .models.log import OrderLogEntry, OrderLogEntryData
30
from .models.order import LineItemID, Order, Orderer, OrderID, PaymentState
31
from .models.payment import AdditionalPaymentData, Payment
32
33
34
OVERDUE_THRESHOLD = timedelta(days=14)
35 1
36
37
def place_order(
38
    created_at: datetime,
39
    shop_id: ShopID,
40
    storefront_id: StorefrontID,
41
    orderer: Orderer,
42
    currency: Currency,
43
    cart: Cart,
44
) -> Result[tuple[IncomingOrder, OrderLogEntry], CartEmpty]:
45
    """Place an order."""
46
    cart_items = cart.get_items()
47
    if not cart_items:
48
        return Err(CartEmpty())
49
50
    line_items = list(_build_incoming_line_items(cart_items))
51
52
    total_amount = cart.calculate_total_amount()
53
54 1
    processing_required = any(
55
        line_item.processing_required for line_item in line_items
56
    )
57
58
    incoming_order = IncomingOrder(
59
        id=OrderID(generate_uuid7()),
60
        created_at=created_at,
61
        shop_id=shop_id,
62
        storefront_id=storefront_id,
63
        orderer=orderer,
64
        line_items=line_items,
65 1
        total_amount=total_amount,
66
        processing_required=processing_required,
67
    )
68
69
    log_entry = _build_order_placed_log_entry(incoming_order)
70
71
    return Ok((incoming_order, log_entry))
72
73
74
def _build_incoming_line_items(
75
    cart_items: list[CartItem],
76
) -> Iterator[IncomingLineItem]:
77
    """Build incoming line item objects from the cart's content."""
78
    for cart_item in cart_items:
79
        article = cart_item.article
80
        quantity = cart_item.quantity
81 1
        line_amount = cart_item.line_amount
82
83
        yield IncomingLineItem(
84
            id=LineItemID(generate_uuid7()),
85
            article_id=article.id,
86
            article_number=article.item_number,
87
            article_type=article.type_,
88
            name=article.name,
89
            unit_price=article.price,
90
            tax_rate=article.tax_rate,
91
            quantity=quantity,
92 1
            line_amount=line_amount,
93
            processing_required=article.processing_required,
94
        )
95
96
97
def _build_order_placed_log_entry(
98
    incoming_order: IncomingOrder,
99
) -> OrderLogEntry:
100
    data = {
101
        'initiator_id': str(incoming_order.orderer.user.id),
102
    }
103
104
    return OrderLogEntry(
105
        id=generate_uuid7(),
106
        occurred_at=incoming_order.created_at,
107
        event_type='order-placed',
108 1
        order_id=incoming_order.id,
109
        data=data,
110
    )
111
112
113
def add_note(order: Order, author: User, text: str) -> OrderLogEntry:
114
    log_entry = _build_note_log_entry(order.id, author, text)
115
116 1
    return log_entry
117
118
119 1
def _build_note_log_entry(
120
    order_id: OrderID,
121 1
    author: User,
122
    text: str,
123
) -> OrderLogEntry:
124 1
    data = {
125
        'author_id': str(author.id),
126
        'text': text,
127
    }
128
129
    return OrderLogEntry(
130
        id=generate_uuid7(),
131 1
        occurred_at=datetime.utcnow(),
132
        event_type='order-note-added',
133
        order_id=order_id,
134
        data=data,
135
    )
136
137
138
def set_shipped_flag(
139
    order: Order, initiator: User
140
) -> Result[OrderLogEntry, str]:
141 1
    if not order.is_processing_required:
142
        return Err('Order contains no items that require shipping.')
143
144 1
    log_entry = _build_set_shipped_flag_log_entry(order.id, initiator)
145
146
    return Ok(log_entry)
147
148
149 1
def _build_set_shipped_flag_log_entry(
150
    order_id: OrderID, initiator: User
151
) -> OrderLogEntry:
152
    data = {
153
        'initiator_id': str(initiator.id),
154
    }
155
156
    return OrderLogEntry(
157
        id=generate_uuid7(),
158 1
        occurred_at=datetime.utcnow(),
159
        event_type='order-shipped',
160
        order_id=order_id,
161
        data=data,
162
    )
163
164
165
def unset_shipped_flag(
166
    order: Order, initiator: User
167
) -> Result[OrderLogEntry, str]:
168
    if not order.is_processing_required:
169 1
        return Err('Order contains no items that require shipping.')
170
171
    log_entry = _build_unset_shipped_flag_log_entry(order.id, initiator)
172 1
173
    return Ok(log_entry)
174 1
175
176
def _build_unset_shipped_flag_log_entry(
177
    order_id: OrderID, initiator: User
178 1
) -> OrderLogEntry:
179
    data = {
180
        'initiator_id': str(initiator.id),
181
    }
182
183
    return OrderLogEntry(
184
        id=generate_uuid7(),
185
        occurred_at=datetime.utcnow(),
186
        event_type='order-shipped-withdrawn',
187 1
        order_id=order_id,
188
        data=data,
189
    )
190 1
191
192
def create_payment(
193
    order: Order,
194
    created_at: datetime,
195
    method: str,
196
    amount: Money,
197 1
    initiator: User,
198
    additional_data: AdditionalPaymentData,
199
) -> tuple[Payment, OrderLogEntry]:
200
    payment = _build_payment(
201
        order.id, created_at, method, amount, additional_data
202
    )
203
    log_entry = _build_payment_log_entry(payment, initiator)
204
205
    return payment, log_entry
206
207
208
def _build_payment(
209 1
    order_id: OrderID,
210
    created_at: datetime,
211
    method: str,
212
    amount: Money,
213
    additional_data: AdditionalPaymentData,
214
) -> Payment:
215
    return Payment(
216
        id=generate_uuid7(),
217 1
        order_id=order_id,
218
        created_at=created_at,
219
        method=method,
220
        amount=amount,
221
        additional_data=additional_data,
222 1
    )
223 1
224
225 1
def _build_payment_log_entry(
226
    payment: Payment, initiator: User
227
) -> OrderLogEntry:
228
    data = {
229
        'payment_id': str(payment.id),
230
        'initiator_id': str(initiator.id),
231
    }
232
233 1
    return OrderLogEntry(
234
        id=generate_uuid7(),
235
        occurred_at=payment.created_at,
236
        event_type='order-payment-created',
237
        order_id=payment.order_id,
238
        data=data,
239
    )
240
241
242 1
def mark_order_as_paid(
243
    order: Order,
244
    orderer_user: User,
245
    occurred_at: datetime,
246
    payment_method: str,
247
    additional_payment_data: AdditionalPaymentData | None,
248
    initiator: User,
249
) -> Result[
250
    tuple[ShopOrderPaidEvent, OrderLogEntry],
251
    OrderAlreadyMarkedAsPaidError,
252 1
]:
253
    if _is_paid(order):
254
        return Err(OrderAlreadyMarkedAsPaidError())
255 1
256
    payment_state_from = order.payment_state
257 1
258
    event = _build_order_paid_event(
259 1
        occurred_at, order, orderer_user, payment_method, initiator
260
    )
261
262
    log_entry = _build_order_paid_log_entry(
263 1
        occurred_at,
264
        order.id,
265
        payment_state_from,
266
        payment_method,
267
        additional_payment_data,
268
        initiator,
269
    )
270
271
    return Ok((event, log_entry))
272 1
273
274
def _build_order_paid_event(
275 1
    occurred_at: datetime,
276
    order: Order,
277
    orderer_user: User,
278
    payment_method: str,
279
    initiator: User,
280
) -> ShopOrderPaidEvent:
281 1
    return ShopOrderPaidEvent(
282
        occurred_at=occurred_at,
283
        initiator=EventUser.from_user(initiator),
284
        order_id=order.id,
285
        order_number=order.order_number,
286
        orderer=EventUser.from_user(orderer_user),
287
        payment_method=payment_method,
288
    )
289
290
291
def _build_order_paid_log_entry(
292 1
    occurred_at: datetime,
293
    order_id: OrderID,
294
    payment_state_from: PaymentState,
295
    payment_method: str,
296
    additional_payment_data: AdditionalPaymentData | None,
297
    initiator: User,
298
) -> OrderLogEntry:
299
    data: OrderLogEntryData = {}
300 1
301
    # Add required, internally set properties after given additional
302
    # ones to ensure the former are not overridden by the latter.
303
304
    if additional_payment_data is not None:
305
        data.update(additional_payment_data)
306 1
307
    data.update(
308
        {
309
            'former_payment_state': payment_state_from.name,
310
            'payment_method': payment_method,
311
            'initiator_id': str(initiator.id),
312 1
        }
313
    )
314
315
    return OrderLogEntry(
316
        id=generate_uuid7(),
317
        occurred_at=occurred_at,
318
        event_type='order-paid',
319
        order_id=order_id,
320
        data=data,
321 1
    )
322 1
323
324
def cancel_order(
325 1
    order: Order,
326 1
    orderer_user: User,
327
    occurred_at: datetime,
328
    reason: str,
329
    initiator: User,
330
) -> Result[
331
    tuple[ShopOrderCanceledEvent, OrderLogEntry],
332 1
    OrderAlreadyCanceledError,
333
]:
334 1
    if _is_canceled(order):
335 1
        return Err(OrderAlreadyCanceledError())
336
337 1
    has_order_been_paid = _is_paid(order)
338
339
    payment_state_from = order.payment_state
340
341
    event = _build_order_canceled_event(
342
        occurred_at, order, orderer_user, initiator
343
    )
344
345
    log_entry = _build_order_canceled_log_entry(
346
        occurred_at,
347
        order.id,
348
        has_order_been_paid,
349
        payment_state_from,
350
        reason,
351
        initiator,
352
    )
353
354
    return Ok((event, log_entry))
355
356
357
def _build_order_canceled_event(
358
    occurred_at: datetime,
359
    order: Order,
360
    orderer_user: User,
361
    initiator: User,
362
) -> ShopOrderCanceledEvent:
363
    return ShopOrderCanceledEvent(
364
        occurred_at=occurred_at,
365
        initiator=EventUser.from_user(initiator),
366
        order_id=order.id,
367
        order_number=order.order_number,
368
        orderer=EventUser.from_user(orderer_user),
369
    )
370
371
372
def _build_order_canceled_log_entry(
373
    occurred_at: datetime,
374
    order_id: OrderID,
375
    has_order_been_paid: bool,
376
    payment_state_from: PaymentState,
377
    reason: str,
378
    initiator: User,
379
) -> OrderLogEntry:
380
    event_type = (
381
        'order-canceled-after-paid'
382
        if has_order_been_paid
383
        else 'order-canceled-before-paid'
384
    )
385
386
    data = {
387
        'former_payment_state': payment_state_from.name,
388
        'reason': reason,
389
        'initiator_id': str(initiator.id),
390
    }
391
392
    return OrderLogEntry(
393
        id=generate_uuid7(),
394
        occurred_at=occurred_at,
395
        event_type=event_type,
396
        order_id=order_id,
397
        data=data,
398
    )
399
400
401
def _is_paid(order: Order) -> bool:
402
    return order.payment_state == PaymentState.paid
403
404
405
def _is_canceled(order: Order) -> bool:
406
    return order.payment_state in {
407
        PaymentState.canceled_before_paid,
408
        PaymentState.canceled_after_paid,
409
    }
410
411
412
def is_overdue(created_at: datetime, payment_state: PaymentState) -> bool:
413
    """Return `True` if payment of the order is overdue."""
414
    if payment_state != PaymentState.open:
415
        return False
416
417
    return datetime.utcnow() >= (created_at + OVERDUE_THRESHOLD)
418