from enum import IntEnum
from more.webassets import WebassetsApp
from onegov.core.orm.cache import request_cached
from onegov.pay import log
from onegov.pay import PaymentProvider
from onegov.pay.errors import CARD_ERRORS
from onegov.pay.models.payment import ManualPayment
from onegov.pay.utils import Price
from typing import Any, TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
from functools import cached_property
from onegov.pay.models.payment import Payment
from onegov.pay.types import PaymentMethod
from sqlalchemy.orm import Session
[docs]
class PayApp(WebassetsApp):
""" Provides payment integration for
:class:`onegov.core.framework.Framework` based applications.
"""
if TYPE_CHECKING:
# forward declare the attributes from Framework we depend on
@cached_property
[docs]
def session(self) -> 'Callable[[], Session]': ...
# NOTE: This is another model where we could probably get away with a
# more long-term cache, but again, we have to prove it's worth it
@request_cached # type:ignore[type-var]
[docs]
def default_payment_provider(self) -> PaymentProvider[Any] | None:
return self.session().query(PaymentProvider).filter(
PaymentProvider.default.is_(True),
PaymentProvider.enabled.is_(True),
).first()
[docs]
def adjust_price(self, price: Price | None) -> Price | None:
""" Takes the given price object and adjusts it depending on the
settings of the payment provider (for example, the fee might be
charged to the user).
"""
if price and price.amount < 0:
# if we somehow got a negative price, treat it the same as no price
return Price(0, price.currency)
if self.default_payment_provider:
return self.default_payment_provider.adjust_price(price)
return price
@PayApp.webasset_path()
[docs]
def get_js_path() -> str:
return 'assets/js'
@PayApp.webasset('pay')
[docs]
def get_pay_assets() -> 'Iterator[str]':
yield 'stripe.js'
[docs]
class PaymentError(IntEnum):
[docs]
INSUFFICIENT_FUNDS = PaymentError.INSUFFICIENT_FUNDS
[docs]
def process_payment(
method: 'PaymentMethod',
price: Price,
provider: PaymentProvider[Any] | None = None,
token: str | None = None
) -> 'Payment | PaymentError | None':
""" Processes a payment using various methods.
This method returns one of the following:
* The processed payment if successful.
* None if an unknown error occurred.
* An error code (see below).
Possible error codes:
* INSUFFICIENT_FUNDS - the card has insufficient funds.
Available methods:
'free': Payment may be done manually or by credit card
'cc': Payment must be done by credit card
'manual': Payment must be done manually
"""
assert method in ('free', 'cc', 'manual') and price.amount > 0
if method == 'free':
method = 'cc' if token else 'manual'
# FIXME: This is kind of bad, we have a default currency of CHF
# for None which either results in an Exception or just
# gets quietly applied depending on whether or not we
# create a ManualPayment or charge through a PaymentProvider
# for now let's always default to CHF, but we should be
# more careful about distinguishing between a Price with
# and without currency and force people to pass a price
# with a currency into this function
currency = price.currency or 'CHF'
if method == 'manual':
return ManualPayment(
amount=price.net_amount,
currency=currency
)
if method == 'cc' and token:
assert provider is not None
try:
return provider.charge(
amount=price.amount,
currency=currency,
token=token
)
except CARD_ERRORS as e:
if 'insufficient funds' in str(e):
return INSUFFICIENT_FUNDS
log.exception(
f'Processing {price} through {provider.title} '
f'with token {token} failed'
)
return None