Skip to content

Commit 9a0ec4d

Browse files
committed
feat: Implement Unzer Paylater Invoice (BNPL)
1 parent 3bd61b9 commit 9a0ec4d

File tree

8 files changed

+174
-6
lines changed

8 files changed

+174
-6
lines changed

src/viur/shop/data/translations.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,16 @@
8686
"_hint": "viur-shop payment provider: unzer-applepay",
8787
"de": "Apple Pay über Unzer",
8888
},
89+
"viur.shop.payment_provider.unzer-paylater_invoice": {
90+
"_default_text": "Invoice via Unzer",
91+
"_hint": "viur-shop Unzer Paylater Invoice",
92+
"de": "Rechnungskauf über Unzer",
93+
},
94+
"viur.shop.payment_provider.unzer-paylater_invoice.descr": {
95+
"_default_text": "Buy Now Pay Later (BNPL)",
96+
"en": "Buy Now Pay Later (BNPL)",
97+
"de": "Jetzt kaufen, später bezahlen",
98+
},
8999
"viur.shop.payment_provider.prepayment": {
90100
"_default_text": "Prepayment",
91101
"_hint": "viur-shop Prepayment",

src/viur/shop/modules/address.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,24 @@ def canAdd(self) -> bool:
4545
return True
4646

4747
def canEdit(self, skel: SkeletonInstance) -> bool:
48-
if super().canEdit(skel):
49-
return True
48+
# if super().canEdit(skel):
49+
# return True
50+
51+
logger.debug(f"canEdit {skel}")
52+
logger.debug((
53+
current.request.get().kwargs,
54+
current.request.get().context.get("order") == self.shop.order.current_session_order_key,
55+
current.request.get().context.get("order") , self.shop.order.current_session_order_key, str(self.shop.order.current_session_order_key),
56+
self.shop.order.current_order_skel["billing_address"]["dest"]["key"],
57+
skel["key"]
58+
))
59+
60+
if (
61+
not (set(current.request.get().kwargs.keys()) - {"birthdate", "skey", "@order"})
62+
and current.request.get().context.get("order") == str(self.shop.order.current_session_order_key)
63+
and self.shop.order.current_order_skel["billing_address"]["dest"]["key"] == skel["key"]
64+
):
65+
return True # only birthdate should be set (e.g. for unzer invoice)
5066

5167
if skel["key"] in self.session.get("created_skel_keys", ()):
5268
logger.debug(f"User added this address in his session: {skel['key']!r}")

src/viur/shop/modules/order.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -297,9 +297,9 @@ def checkout_start(
297297
}, status_code=400)
298298
raise e.InvalidStateError(", ".join(errors))
299299

300-
if order_skel["cart"]["dest"]["key"] == self.shop.cart.current_session_cart_key:
301-
# This is now an order basket and should no longer be modified
302-
self.shop.cart.detach_session_cart()
300+
# if order_skel["cart"]["dest"]["key"] == self.shop.cart.current_session_cart_key:
301+
# # This is now an order basket and should no longer be modified
302+
# self.shop.cart.detach_session_cart()
303303

304304
order_skel = self.freeze_order(order_skel)
305305
try:

src/viur/shop/payment_providers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,6 @@
1717
from .unzer_card import UnzerCard
1818
from .unzer_googlepay import UnzerGooglepay
1919
from .unzer_ideal import UnzerIdeal
20+
from .unzer_paylater_invoice import UnzerPaylaterInvoice
2021
from .unzer_paypal import UnzerPayPal
2122
from .unzer_sofort import UnzerSofort

src/viur/shop/payment_providers/unzer_abstract.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,8 @@ def save_type(
459459
"type_id": type_id,
460460
"charged": False, # TODO: Set value
461461
"aborted": False, # TODO: Set value
462+
"client_ip": current.request.get().request.client_addr,
463+
"user_agent": current.request.get().request.user_agent,
462464
}
463465
)
464466
return JsonResponse(order_skel)
@@ -479,6 +481,7 @@ def customer_from_order_skel(
479481
customerId=self.customer_id_from_order_skel(order_skel),
480482
email=ba["email"],
481483
phone=ba["phone"],
484+
birthDate=ba["birthdate"],
482485
billingAddress=self.address_from_address_skel(ba),
483486
shippingAddress=self.address_from_address_skel(sa),
484487
)
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import typing as t
2+
import abc
3+
import enum
4+
import functools
5+
import json
6+
import typing as t # noqa
7+
8+
import unzer
9+
from unzer.model import PaymentType
10+
from unzer.model.base import BaseModel
11+
from unzer.model.customer import Salutation as UnzerSalutation
12+
from unzer.model.payment import PaymentState
13+
from unzer.model.webhook import Events, IP_ADDRESS
14+
from viur import toolkit
15+
from viur.core import CallDeferred, access, current, db, errors, exposed, force_post
16+
from viur.core.skeleton import SkeletonInstance
17+
from viur.shop.skeletons import OrderSkel
18+
from viur.shop.types import *
19+
20+
from . import PaymentProviderAbstract
21+
from ..globals import SHOP_LOGGER
22+
from ..services import HOOK_SERVICE, Hook
23+
from ..types import exceptions as e
24+
25+
import unzer
26+
from unzer.model import PaymentType
27+
from viur.core.skeleton import SkeletonInstance
28+
29+
from .unzer_abstract import UnzerAbstract, log_unzer_error
30+
from ..globals import SHOP_LOGGER
31+
32+
logger = SHOP_LOGGER.getChild(__name__)
33+
34+
35+
class UnzerPaylaterInvoice(UnzerAbstract):
36+
"""
37+
Unzer Paylater Invoice payment method integration for the ViUR Shop.
38+
39+
Enables customers to pay using invoice through the Unzer payment gateway.
40+
"""
41+
42+
name: t.Final[str] = "unzer-paylater_invoice"
43+
44+
def can_order(
45+
self,
46+
order_skel: SkeletonInstance_T[OrderSkel],
47+
) -> list[ClientError]:
48+
order_skel = OrderSkel.refresh_billing_address(order_skel)
49+
errs = super().can_order(order_skel)
50+
if not order_skel["billing_address"] or not order_skel["billing_address"]["dest"]["birthdate"]:
51+
errs.append(ClientError("billing_address has no birthday set"))
52+
return errs
53+
54+
@log_unzer_error
55+
def checkout(
56+
self,
57+
order_skel: SkeletonInstance,
58+
) -> t.Any:
59+
order_skel = OrderSkel.refresh_billing_address(order_skel)
60+
if not order_skel["billing_address"]["dest"]["birthdate"]:
61+
raise errors.PreconditionFailed("Billing address has no birthdate")
62+
customer = self.customer_from_order_skel(order_skel)
63+
logger.debug(f"{customer = }")
64+
65+
customer = self.client.createOrUpdateCustomer(customer)
66+
logger.debug(f"{customer = } [RESPONSE]")
67+
68+
host = current.request.get().request.host_url
69+
return_url = f'{host.rstrip("/")}/{self.modulePath.strip("/")}/return_handler?order_key={order_skel["key"].to_legacy_urlsafe().decode("ASCII")}'
70+
unzer_session = current.session.get()["unzer"] = {
71+
"customer_id": customer.key,
72+
}
73+
payment = self.client.authorize(
74+
# TODO: x-CLIENTIP=<YOUR Client's IP>
75+
unzer.PaymentRequest(
76+
self.get_payment_type(order_skel),
77+
amount=order_skel["total"],
78+
returnUrl=return_url,
79+
card3ds=True,
80+
customerId=customer.key,
81+
orderId=order_skel["key"].id_or_name,
82+
invoiceId=order_skel["order_uid"],
83+
)
84+
)
85+
logger.debug(f"{payment=} [authorize response]")
86+
unzer_session["paymentId"] = payment.paymentId
87+
unzer_session["redirectUrl"] = payment.redirectUrl
88+
processing_data = payment.processing.asDict()
89+
90+
payment = payment.charge(
91+
amount=order_skel["total"]
92+
)
93+
logger.debug(f"{payment=} [charge response]")
94+
95+
logger.debug(f"{unzer_session = }")
96+
current.session.get().markChanged()
97+
98+
def set_payment(skel: SkeletonInstance):
99+
skel["payment"]["payments"][-1]["payment_id"] = payment.paymentId
100+
skel["payment"]["payments"][-1]["processing_data"] = processing_data
101+
102+
order_skel = toolkit.set_status(
103+
key=order_skel["key"],
104+
values=set_payment,
105+
skel=order_skel,
106+
)
107+
108+
return unzer_session
109+
110+
111+
112+
def get_payment_type(
113+
self,
114+
order_skel: SkeletonInstance,
115+
) -> PaymentType:
116+
type_id = order_skel["payment"]["payments"][-1]["type_id"]
117+
return unzer.PaylaterInvoice(key=type_id)

src/viur/shop/skeletons/address.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,14 @@ class AddressSkel(Skeleton):
134134
searchable=True,
135135
)
136136

137+
birthdate = DateBone(
138+
params={
139+
"group": "Customer Info",
140+
},
141+
localize=False,
142+
naive=True,
143+
)
144+
137145
# FIXME: What happens if an AddressSkel has both address_types and is_default
138146
# and you add an new default AddressSkel with only one address_type?
139147
is_default = BooleanBone(

src/viur/shop/skeletons/order.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class OrderSkel(Skeleton):
2727
"key", "name", "customer_type", "salutation", "company_name",
2828
"firstname", "lastname", "street_name", "street_number",
2929
"address_addition", "zip_code", "city", "country",
30-
"email", "phone",
30+
"email", "phone", "birthdate",
3131
"is_default", "address_type",
3232
],
3333
searchable=True,
@@ -142,6 +142,19 @@ def refresh_cart(cls, skel: SkeletonInstance) -> SkeletonInstance:
142142
logger.debug(f'Failed to refresh cart on order {skel["key"]!r}: {exc}')
143143
return skel
144144

145+
@classmethod
146+
def refresh_billing_address(cls, skel: SkeletonInstance) -> SkeletonInstance:
147+
"""
148+
Shorthand to refresh the billing_address of an OrderSkel
149+
Due to race-condition and timing issues, the dest values are not always
150+
set correctly. This refresh fixes this.
151+
"""
152+
try:
153+
skel.billing_address.refresh(skel, skel.billing_address.name)
154+
except Exception as exc:
155+
logger.info(f'Failed to refresh billing_address on order {skel["key"]!r}: {exc}')
156+
return skel
157+
145158
@classmethod
146159
def read(cls, skel: SkeletonInstance, *args, **kwargs) -> t.Optional[SkeletonInstance]:
147160
if res := super().read(skel, *args, **kwargs):

0 commit comments

Comments
 (0)