keble-payment 2.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. keble_payment/__init__.py +5 -0
  2. keble_payment/alipay/__init__.py +1 -0
  3. keble_payment/alipay/client.py +199 -0
  4. keble_payment/crud/__init__.py +14 -0
  5. keble_payment/crud/alipay.py +27 -0
  6. keble_payment/crud/bank_transaction.py +27 -0
  7. keble_payment/crud/coupon.py +254 -0
  8. keble_payment/crud/coupon_history.py +297 -0
  9. keble_payment/crud/order.py +20 -0
  10. keble_payment/crud/payment.py +35 -0
  11. keble_payment/crud/paypal_payout.py +416 -0
  12. keble_payment/crud/stripe_card.py +140 -0
  13. keble_payment/crud/stripe_checkout_session.py +247 -0
  14. keble_payment/crud/stripe_customer.py +135 -0
  15. keble_payment/crud/stripe_invoice.py +221 -0
  16. keble_payment/crud/stripe_payment_intent.py +252 -0
  17. keble_payment/crud/stripe_payment_method.py +180 -0
  18. keble_payment/crud/stripe_subscription.py +430 -0
  19. keble_payment/main/__init__.py +29 -0
  20. keble_payment/main/base.py +141 -0
  21. keble_payment/main/client.py +131 -0
  22. keble_payment/main/coupon.py +298 -0
  23. keble_payment/main/order.py +3252 -0
  24. keble_payment/main/paypal_webhook_handler.py +366 -0
  25. keble_payment/main/stripe_webhook_handler.py +348 -0
  26. keble_payment/main/utils.py +126 -0
  27. keble_payment/paypal/__init__.py +24 -0
  28. keble_payment/paypal/api/__init__.py +9 -0
  29. keble_payment/paypal/api/base.py +19 -0
  30. keble_payment/paypal/api/payout.py +163 -0
  31. keble_payment/paypal/client.py +361 -0
  32. keble_payment/paypal/config.py +54 -0
  33. keble_payment/paypal/enum.py +148 -0
  34. keble_payment/paypal/schemas/__init__.py +45 -0
  35. keble_payment/paypal/schemas/payout.py +603 -0
  36. keble_payment/paypal/schemas/webhook.py +21 -0
  37. keble_payment/paypal/utils.py +21 -0
  38. keble_payment/protocols.py +26 -0
  39. keble_payment/schemas/__init__.py +100 -0
  40. keble_payment/schemas/alipay.py +51 -0
  41. keble_payment/schemas/bank_transaction.py +26 -0
  42. keble_payment/schemas/base.py +15 -0
  43. keble_payment/schemas/coupon.py +182 -0
  44. keble_payment/schemas/coupon_attribution.py +378 -0
  45. keble_payment/schemas/coupon_history.py +40 -0
  46. keble_payment/schemas/enum.py +118 -0
  47. keble_payment/schemas/order.py +168 -0
  48. keble_payment/schemas/payment.py +34 -0
  49. keble_payment/schemas/paypal_payout.py +162 -0
  50. keble_payment/schemas/stripe_card.py +66 -0
  51. keble_payment/schemas/stripe_charge.py +89 -0
  52. keble_payment/schemas/stripe_checkout_session.py +99 -0
  53. keble_payment/schemas/stripe_customer.py +63 -0
  54. keble_payment/schemas/stripe_invoice.py +463 -0
  55. keble_payment/schemas/stripe_payment_intent.py +160 -0
  56. keble_payment/schemas/stripe_payment_method.py +70 -0
  57. keble_payment/schemas/stripe_subscription.py +168 -0
  58. keble_payment/stripe/__init__.py +1 -0
  59. keble_payment/stripe/api/__init__.py +45 -0
  60. keble_payment/stripe/api/base.py +41 -0
  61. keble_payment/stripe/api/billing_portal_session.py +37 -0
  62. keble_payment/stripe/api/charge.py +60 -0
  63. keble_payment/stripe/api/checkout_session.py +107 -0
  64. keble_payment/stripe/api/customer.py +92 -0
  65. keble_payment/stripe/api/event.py +43 -0
  66. keble_payment/stripe/api/invoice.py +174 -0
  67. keble_payment/stripe/api/payment_intent.py +190 -0
  68. keble_payment/stripe/api/payment_methods.py +200 -0
  69. keble_payment/stripe/api/portal_configuration.py +104 -0
  70. keble_payment/stripe/api/price.py +212 -0
  71. keble_payment/stripe/api/refund.py +98 -0
  72. keble_payment/stripe/api/setup_intent.py +182 -0
  73. keble_payment/stripe/api/subscription.py +222 -0
  74. keble_payment/stripe/api/subscription_schedule.py +192 -0
  75. keble_payment/stripe/api/transfer.py +118 -0
  76. keble_payment/stripe/config.py +21 -0
  77. keble_payment/stripe/enum.py +770 -0
  78. keble_payment/stripe/exceptions/__init__.py +1 -0
  79. keble_payment/stripe/exceptions/utils.py +2 -0
  80. keble_payment/stripe/schemas/__init__.py +255 -0
  81. keble_payment/stripe/schemas/address.py +27 -0
  82. keble_payment/stripe/schemas/amount.py +9 -0
  83. keble_payment/stripe/schemas/balance_transaction.py +91 -0
  84. keble_payment/stripe/schemas/billing.py +41 -0
  85. keble_payment/stripe/schemas/billing_portal_session.py +254 -0
  86. keble_payment/stripe/schemas/cash_balance.py +18 -0
  87. keble_payment/stripe/schemas/charge.py +390 -0
  88. keble_payment/stripe/schemas/checkout_session.py +1630 -0
  89. keble_payment/stripe/schemas/checks.py +10 -0
  90. keble_payment/stripe/schemas/coupon.py +131 -0
  91. keble_payment/stripe/schemas/customer.py +652 -0
  92. keble_payment/stripe/schemas/detailed.py +433 -0
  93. keble_payment/stripe/schemas/discount.py +49 -0
  94. keble_payment/stripe/schemas/dispute.py +45 -0
  95. keble_payment/stripe/schemas/event.py +62 -0
  96. keble_payment/stripe/schemas/evidence_detail.py +24 -0
  97. keble_payment/stripe/schemas/fee.py +19 -0
  98. keble_payment/stripe/schemas/invoice.py +642 -0
  99. keble_payment/stripe/schemas/out_come.py +13 -0
  100. keble_payment/stripe/schemas/payment_channels.py +224 -0
  101. keble_payment/stripe/schemas/payment_error.py +32 -0
  102. keble_payment/stripe/schemas/payment_intent.py +517 -0
  103. keble_payment/stripe/schemas/payment_method.py +177 -0
  104. keble_payment/stripe/schemas/portal_configuration.py +412 -0
  105. keble_payment/stripe/schemas/price.py +524 -0
  106. keble_payment/stripe/schemas/refund.py +150 -0
  107. keble_payment/stripe/schemas/setup_error.py +27 -0
  108. keble_payment/stripe/schemas/setup_intent.py +228 -0
  109. keble_payment/stripe/schemas/shipping.py +20 -0
  110. keble_payment/stripe/schemas/subscription.py +571 -0
  111. keble_payment/stripe/schemas/subscription_schedule.py +174 -0
  112. keble_payment/stripe/schemas/tip.py +8 -0
  113. keble_payment/stripe/schemas/transfer.py +136 -0
  114. keble_payment/stripe/schemas/transfer_data.py +18 -0
  115. keble_payment/stripe/schemas/transfer_reversal.py +26 -0
  116. keble_payment/stripe/schemas/utils.py +42 -0
  117. keble_payment/testing/__init__.py +6 -0
  118. keble_payment/testing/config.py +205 -0
  119. keble_payment/testing/helpers.py +17 -0
  120. keble_payment/utils.py +25 -0
  121. keble_payment-2.4.1.dist-info/METADATA +132 -0
  122. keble_payment-2.4.1.dist-info/RECORD +123 -0
  123. keble_payment-2.4.1.dist-info/WHEEL +4 -0
@@ -0,0 +1,5 @@
1
+ from . import stripe
2
+ from .alipay import * # noqa: F403
3
+ from .main import * # noqa: F403
4
+ from .protocols import CouponAttributionProviderProtocol
5
+ from .schemas import * # noqa: F403
@@ -0,0 +1 @@
1
+ from .client import AlipayClient, AlipayPaymentSuccessNotification
@@ -0,0 +1,199 @@
1
+ import logging
2
+ from typing import Optional
3
+
4
+ import alipay
5
+ import keble_exceptions
6
+ from keble_helpers import ObjectId
7
+ from pydantic import BaseModel
8
+
9
+ from ..utils import std_money_int_to_float
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class AlipayPaymentSuccessNotification(BaseModel):
15
+ raw_data: dict
16
+ money: int
17
+ app_id: Optional[str] = None
18
+ trade_no: str
19
+
20
+
21
+ class AlipayClient:
22
+ def __init__(
23
+ self,
24
+ *,
25
+ app_private_key_string: str,
26
+ alipay_public_key_string: str,
27
+ app_id: str,
28
+ app_notify_url: str,
29
+ sign_type: str = "RSA2",
30
+ debug: bool = False,
31
+ dev_test: bool = False,
32
+ ):
33
+ self._app_id = app_id
34
+ self._app_notify_url = app_notify_url
35
+ self._dev_test = dev_test
36
+ self._ac = None
37
+ if app_private_key_string != "" and alipay_public_key_string != "":
38
+ self._ac = alipay.AliPay(
39
+ appid=app_id,
40
+ app_notify_url=app_notify_url, # 默认回调url
41
+ app_private_key_string=app_private_key_string,
42
+ # 支付宝的公钥,验证支付宝回传消息使用,不是你自己的公钥,
43
+ alipay_public_key_string=alipay_public_key_string,
44
+ sign_type=sign_type, # RSA 或者 RSA2
45
+ debug=debug, # 默认False
46
+ )
47
+ elif not dev_test:
48
+ raise keble_exceptions.ClientSideInvalidParams(
49
+ invalid_params="alipay_key_material",
50
+ expected="Alipay private and public key strings",
51
+ but_got="missing",
52
+ alert_admin=True,
53
+ admin_note="AlipayClient requires key material outside dev_test mode.",
54
+ )
55
+
56
+ def _require_alipay_client(self):
57
+ """Return the SDK client for methods that call real Alipay APIs."""
58
+
59
+ if self._ac is None:
60
+ raise keble_exceptions.ClientSideInvalidParams(
61
+ invalid_params="alipay_key_material",
62
+ expected="Alipay private and public key strings",
63
+ but_got="missing",
64
+ alert_admin=True,
65
+ admin_note="This Alipay operation requires key-backed SDK client setup.",
66
+ )
67
+ return self._ac
68
+
69
+ def transfer(
70
+ self,
71
+ *,
72
+ transfer_id: str,
73
+ to_alipay_account: str, # email
74
+ money: int,
75
+ payee_real_name: str,
76
+ remark: str,
77
+ payer_name: str,
78
+ ) -> dict:
79
+ """fund transaction in alipay"""
80
+
81
+ to_alipay_account = str(to_alipay_account)
82
+ if (
83
+ len(to_alipay_account) == 16
84
+ and to_alipay_account[:4] == "2088"
85
+ and "@" not in to_alipay_account
86
+ ):
87
+ # https://opendocs.alipay.com/open/309/alipay.fund.trans.toaccount.transfer
88
+ payee_type = "ALIPAY_USERID"
89
+ else:
90
+ payee_type = "ALIPAY_LOGONID"
91
+ adjusted_money_amount = std_money_int_to_float(money)
92
+ if adjusted_money_amount >= 4000:
93
+ raise keble_exceptions.ClientSideInvalidParams(
94
+ invalid_params="money",
95
+ expected="< 4000",
96
+ but_got=str(adjusted_money_amount),
97
+ alert_admin=True,
98
+ admin_note="Exceeded maximum threshold 4000 for Alipay transfer",
99
+ )
100
+ # fund_trans_id = datetime.now().strftime("%Y%m%d%H%M%S")
101
+
102
+ try:
103
+ result = (
104
+ self._require_alipay_client().api_alipay_fund_trans_toaccount_transfer(
105
+ out_biz_no=transfer_id,
106
+ payee_type=payee_type,
107
+ payee_account=to_alipay_account,
108
+ amount=adjusted_money_amount,
109
+ payee_real_name=payee_real_name, # 收款方姓名(可选,若不匹配则转账失败)
110
+ remark=remark, # 转账备注
111
+ payer_show_name=payer_name, # 付款方姓名
112
+ )
113
+ )
114
+ if "code" in result and "msg" in result:
115
+ if (
116
+ str(result["code"]) == "10000"
117
+ and result["msg"].lower() == "success"
118
+ ):
119
+ return result
120
+ except Exception as e:
121
+ logger.error(
122
+ "[Payment] Alipay Error. Error return from alipay transfer API, by python sdk: {}".format(
123
+ e
124
+ )
125
+ )
126
+ raise e
127
+ raise keble_exceptions.RequestFailure(
128
+ admin_note=f"Alipay transaction issued. But unable to identify result as success. See result: {result}",
129
+ function_identifier="alipay.transfer",
130
+ alert_admin=True,
131
+ )
132
+
133
+ def parse_payment_success_notification(
134
+ self, data: dict
135
+ ) -> Optional[AlipayPaymentSuccessNotification]:
136
+ # Fetch remote order data status directly to validate the payment status
137
+ if data["trade_status"] in ["TRADE_FINISHED", "TRADE_SUCCESS"]:
138
+ retried = 0
139
+ max_retried = 10
140
+ while retried < max_retried:
141
+ try:
142
+ if self._dev_test:
143
+ # use the raw data, do not fetch from alipay
144
+ result = data
145
+ else:
146
+ result = self._require_alipay_client().api_alipay_trade_query(
147
+ out_trade_no=data["out_trade_no"]
148
+ )
149
+ logger.info(
150
+ f"[Payment] Alipay check trade query. Retry {retried}. Result: {result}"
151
+ )
152
+ if result["trade_status"] in ["TRADE_FINISHED", "TRADE_SUCCESS"]:
153
+ # receipt_amount = float(data["receipt_amount"])
154
+ if "total_amount" in data:
155
+ # prioritize total_amount key
156
+ # base on alipay doc, total_amount is the actual value set by merchant
157
+ receipt_amount = float(data["total_amount"])
158
+ elif "receipt_amount" in data:
159
+ # receipt amount key can be misleading, therefore make it as a fallback
160
+ receipt_amount = float(data["receipt_amount"])
161
+ money_int = int(receipt_amount * 100)
162
+ return AlipayPaymentSuccessNotification(
163
+ raw_data=data,
164
+ money=money_int,
165
+ app_id=data.get("app_id"),
166
+ trade_no=data["trade_no"],
167
+ )
168
+ except Exception as e:
169
+ logger.info(f"[Payment] Alipay check trade query. Exception: {e}")
170
+ retried += 1
171
+ return None
172
+
173
+ def build_alipay_wap_payment_url(
174
+ self,
175
+ *,
176
+ payment_id: ObjectId,
177
+ money: int,
178
+ subject: str,
179
+ return_url: str,
180
+ notify_url: str,
181
+ hb_fq_num=None,
182
+ hb_fq_seller_percent: float = 0,
183
+ ) -> str:
184
+ if hb_fq_num is None or hb_fq_num == 1:
185
+ order_string = self._require_alipay_client().api_alipay_trade_wap_pay(
186
+ out_trade_no=str(payment_id),
187
+ total_amount=std_money_int_to_float(money),
188
+ subject=subject,
189
+ return_url=return_url,
190
+ notify_url=notify_url, # 可选, 不填则使用默认notify url
191
+ )
192
+ else:
193
+ raise keble_exceptions.UnhandledScenarioOrCase(
194
+ unhandled_case=f"hb_fq_num={hb_fq_num}",
195
+ alert_admin=True,
196
+ admin_note=f"Missing ALIPAY PID for this hb_fq_num {hb_fq_num}",
197
+ )
198
+
199
+ return "https://openapi.alipay.com/gateway.do?" + order_string
@@ -0,0 +1,14 @@
1
+ from .alipay import CRUDAlipay
2
+ from .bank_transaction import CRUDBankTransaction
3
+ from .coupon import CRUDCoupon
4
+ from .coupon_history import CRUDCouponHistory
5
+ from .order import CRUDOrder
6
+ from .payment import CRUDPayment
7
+ from .stripe_card import CRUDStripeCard
8
+ from .stripe_checkout_session import CRUDStripeCheckoutSession
9
+ from .stripe_customer import CRUDStripeCustomer
10
+ from .stripe_payment_intent import CRUDStripePaymentIntent
11
+ from .stripe_payment_method import CRUDStripePaymentMethod
12
+ from .stripe_subscription import CRUDStripeSubscription
13
+ from .stripe_invoice import CRUDStripeInvoice
14
+ from .paypal_payout import CRUDPayPalPayoutBatch, CRUDPayPalPayoutItem
@@ -0,0 +1,27 @@
1
+ from typing import Optional
2
+
3
+ from keble_db import MongoCRUDBase, QueryBase
4
+ from keble_helpers import ObjectId
5
+ from motor.motor_asyncio import AsyncIOMotorClient
6
+ from pymongo import ASCENDING, IndexModel
7
+
8
+ from keble_payment import schemas
9
+
10
+
11
+ class CRUDAlipay(MongoCRUDBase[schemas.AlipayMongoObject]):
12
+ async def aensure_indexes(self, m: AsyncIOMotorClient) -> None:
13
+ """Create Mongo indexes for Alipay payment side-table reads.
14
+
15
+ Step by step:
16
+ 1. select the Alipay side-table collection;
17
+ 2. index payment-id lookup filters.
18
+ """
19
+
20
+ await m[self.database][self.collection].create_indexes(
21
+ [IndexModel([("payment", ASCENDING)], name="payment_idx")]
22
+ )
23
+
24
+ async def aget_by_payment(
25
+ self, m: AsyncIOMotorClient, *, payment_id: ObjectId
26
+ ) -> Optional[schemas.AlipayMongoObject]:
27
+ return await self.afirst(m, query=QueryBase(filters={"payment": payment_id}))
@@ -0,0 +1,27 @@
1
+ from typing import Optional
2
+
3
+ from keble_db import MongoCRUDBase, QueryBase
4
+ from keble_helpers import ObjectId
5
+ from motor.motor_asyncio import AsyncIOMotorClient
6
+ from pymongo import ASCENDING, IndexModel
7
+
8
+ from keble_payment import schemas
9
+
10
+
11
+ class CRUDBankTransaction(MongoCRUDBase[schemas.BankTransactionMongoObject]):
12
+ async def aensure_indexes(self, m: AsyncIOMotorClient) -> None:
13
+ """Create Mongo indexes for bank-transaction payment reads.
14
+
15
+ Step by step:
16
+ 1. select the bank transaction side-table collection;
17
+ 2. index payment-id lookup filters.
18
+ """
19
+
20
+ await m[self.database][self.collection].create_indexes(
21
+ [IndexModel([("payment", ASCENDING)], name="payment_idx")]
22
+ )
23
+
24
+ async def aget_by_payment(
25
+ self, m: AsyncIOMotorClient, *, payment_id: ObjectId
26
+ ) -> Optional[schemas.BankTransactionMongoObject]:
27
+ return await self.afirst(m, query=QueryBase(filters={"payment": payment_id}))
@@ -0,0 +1,254 @@
1
+ import datetime
2
+ from typing import Optional
3
+
4
+ import keble_exceptions
5
+ from bson import ObjectId
6
+ from keble_db import MongoCRUDBase, QueryBase
7
+ from keble_helpers import Currency, Marketplace
8
+ from motor.motor_asyncio import AsyncIOMotorClient
9
+ from pymongo import ASCENDING, DESCENDING, TEXT, IndexModel
10
+
11
+ from keble_payment import schemas
12
+
13
+
14
+ class CRUDCoupon(MongoCRUDBase[schemas.CouponMongoObject]):
15
+ async def aensure_indexes(self, m: AsyncIOMotorClient) -> None:
16
+ """Create Mongo indexes for coupon code and expiry reads.
17
+
18
+ Step by step:
19
+ 1. select the CRUD-owned coupon collection;
20
+ 2. index active coupon lookups by code and expiry timestamp.
21
+ """
22
+
23
+ await m[self.database][self.collection].create_indexes(
24
+ [
25
+ IndexModel(
26
+ [("code", ASCENDING), ("expired_at", ASCENDING)],
27
+ name="code_expired_at_idx",
28
+ ),
29
+ IndexModel(
30
+ [
31
+ ("attribution_status", ASCENDING),
32
+ ("created", DESCENDING),
33
+ ("_id", DESCENDING),
34
+ ],
35
+ name="attribution_status_created_desc_idx",
36
+ ),
37
+ IndexModel(
38
+ [("created", DESCENDING), ("_id", DESCENDING)],
39
+ name="created_desc_idx",
40
+ ),
41
+ IndexModel(
42
+ [("code", ASCENDING), ("_id", ASCENDING)],
43
+ name="code_id_idx",
44
+ ),
45
+ IndexModel(
46
+ [("consumed", DESCENDING), ("_id", DESCENDING)],
47
+ name="consumed_desc_idx",
48
+ ),
49
+ IndexModel(
50
+ [("expired_at", DESCENDING), ("_id", DESCENDING)],
51
+ name="expired_at_desc_idx",
52
+ ),
53
+ IndexModel(
54
+ [("code", TEXT), ("referrer", TEXT), ("tag_keys", TEXT)],
55
+ name="coupon_search_text_idx",
56
+ ),
57
+ ]
58
+ )
59
+
60
+ async def amigrate_attribution_status(self, m: AsyncIOMotorClient) -> None:
61
+ """Backfill the indexed assignment status for legacy coupon documents.
62
+
63
+ Steps:
64
+ 1. mark documents with at least one tag key as ASSIGNED;
65
+ 2. mark missing/empty tag arrays as UNASSIGNED;
66
+ 3. update only mismatched rows so startup remains idempotent.
67
+
68
+ Side effects if changes:
69
+ - admin unassigned pagination and batch proposal scans use this field;
70
+ - backend startup invokes this before serving coupon reads.
71
+ """
72
+
73
+ collection = m[self.database][self.collection]
74
+ await collection.update_many(
75
+ {
76
+ "tag_keys.0": {"$exists": True},
77
+ "attribution_status": {
78
+ "$ne": schemas.CouponAttributionStatus.ASSIGNED.value
79
+ },
80
+ },
81
+ {
82
+ "$set": {
83
+ "attribution_status": schemas.CouponAttributionStatus.ASSIGNED.value
84
+ }
85
+ },
86
+ )
87
+ await collection.update_many(
88
+ {
89
+ "tag_keys.0": {"$exists": False},
90
+ "attribution_status": {
91
+ "$ne": schemas.CouponAttributionStatus.UNASSIGNED.value
92
+ },
93
+ },
94
+ {
95
+ "$set": {
96
+ "attribution_status": schemas.CouponAttributionStatus.UNASSIGNED.value
97
+ }
98
+ },
99
+ )
100
+
101
+ async def alist_page(
102
+ self,
103
+ m: AsyncIOMotorClient,
104
+ *,
105
+ query: schemas.CouponListQuery,
106
+ ) -> schemas.CouponPage:
107
+ """List coupons through the canonical server-side filtered query.
108
+
109
+ Collection/query/index contract:
110
+ - collection: this CRUD's coupon collection;
111
+ - equality filter: ``attribution_status``;
112
+ - optional filters: text-indexed code/referrer/tag search and expiry;
113
+ - sort: requested supported field plus ``_id`` tie-breaker;
114
+ - indexes: ``attribution_status_created_desc_idx`` for review queues and
115
+ ``created_desc_idx`` plus explicit code/consumed/expiry sort indexes,
116
+ and ``coupon_search_text_idx`` for admin search.
117
+
118
+ Side effects if changes:
119
+ - backend ``GET /api/v3/admin/coupons/list`` exposes this page;
120
+ - frontend admin pagination must mirror its count/sort semantics.
121
+ """
122
+
123
+ filters: dict = {}
124
+ if query.attribution_status is not None:
125
+ filters["attribution_status"] = query.attribution_status.value
126
+ if query.hide_expired:
127
+ filters["$or"] = [
128
+ {"expired_at": None},
129
+ {"expired_at": {"$gte": datetime.datetime.now(datetime.UTC)}},
130
+ ]
131
+ if query.search is not None and query.search.strip():
132
+ filters["$text"] = {"$search": query.search.strip()}
133
+
134
+ sort_fields = {
135
+ schemas.CouponListSortBy.CREATED: "created",
136
+ schemas.CouponListSortBy.CODE: "code",
137
+ schemas.CouponListSortBy.CONSUMED: "consumed",
138
+ schemas.CouponListSortBy.EXPIRED_AT: "expired_at",
139
+ }
140
+ direction = (
141
+ ASCENDING
142
+ if query.sort_direction is schemas.CouponListSortDirection.ASC
143
+ else DESCENDING
144
+ )
145
+ collection = m[self.database][self.collection]
146
+ total = await collection.count_documents(filters)
147
+ cursor = (
148
+ collection.find(filters)
149
+ .sort([(sort_fields[query.sort_by], direction), ("_id", direction)])
150
+ .skip(query.offset)
151
+ .limit(query.limit)
152
+ )
153
+ items = [self._to_model(document) async for document in cursor]
154
+ return schemas.CouponPage(
155
+ items=items,
156
+ total=total,
157
+ offset=query.offset,
158
+ limit=query.limit,
159
+ )
160
+
161
+ async def aget_coupon(
162
+ self,
163
+ m: AsyncIOMotorClient,
164
+ *,
165
+ code: Optional[str] = None,
166
+ _id: Optional[str | ObjectId] = None,
167
+ ):
168
+ if code is None and _id is None:
169
+ raise keble_exceptions.ServerSideMissingParams(
170
+ missing_params="code or _id",
171
+ alert_admin=True,
172
+ admin_note="You must provide either code or _id parameter to get_coupon",
173
+ )
174
+ return await self.afirst(
175
+ m,
176
+ query=QueryBase(
177
+ filters={
178
+ "$or": [
179
+ {"code": code},
180
+ {"_id": ObjectId(_id) if _id is not None else _id},
181
+ ],
182
+ }
183
+ ),
184
+ )
185
+
186
+ async def acan_create_code(
187
+ self,
188
+ m: AsyncIOMotorClient,
189
+ *,
190
+ code: str,
191
+ ) -> bool:
192
+ # ensure there are no same consumable coupon for this code
193
+ exp_or = [
194
+ {"expired_at": {"$eq": None}},
195
+ {"expired_at": {"$gte": datetime.datetime.now()}},
196
+ ]
197
+ _filter = {"code": code, "$or": exp_or}
198
+ coupon = await self.afirst(m, query=QueryBase(filters=_filter))
199
+ # if coupon is None and coupon is exhausted at the moment
200
+ return coupon is None or coupon.exhausted
201
+
202
+ async def aget_consumable_coupon(
203
+ self,
204
+ m: AsyncIOMotorClient,
205
+ *,
206
+ code: Optional[str] = None,
207
+ coupon_id: Optional[ObjectId] = None,
208
+ marketplace: Marketplace,
209
+ currency: Currency,
210
+ ):
211
+ exp_or = [
212
+ {"expired_at": {"$eq": None}},
213
+ {"expired_at": {"$gte": datetime.datetime.now()}},
214
+ ]
215
+ mkt_or = [{"marketplaces": {"$eq": None}}, {"marketplaces": marketplace}]
216
+ cur_or = [{"currencies": {"$eq": None}}, {"currencies": currency}]
217
+ _filter = {
218
+ "$and": [
219
+ {
220
+ "$or": [
221
+ {"$and": [{"code": code}, {"code": {"$ne": None}}]},
222
+ {"_id": coupon_id},
223
+ ]
224
+ },
225
+ {"$or": cur_or},
226
+ {"$or": mkt_or},
227
+ {"$or": exp_or},
228
+ ]
229
+ }
230
+ coupon = await self.afirst(m, query=QueryBase(filters=_filter))
231
+ if coupon is None or coupon.exhausted:
232
+ return None
233
+ return coupon
234
+
235
+ async def aincrement_coupon_usage(
236
+ self,
237
+ m: AsyncIOMotorClient,
238
+ *,
239
+ coupon_id: ObjectId,
240
+ increment: int = 1,
241
+ ):
242
+ latest_coupon = await self.afirst_by_id(m, _id=coupon_id)
243
+ if latest_coupon is None:
244
+ raise keble_exceptions.ObjectNotFound(
245
+ object_name="coupon",
246
+ id=str(coupon_id),
247
+ alert_admin=True,
248
+ admin_note=f"Coupon with id {coupon_id} not found when incrementing usage",
249
+ )
250
+ await self.aupdate(
251
+ m,
252
+ _id=latest_coupon.id,
253
+ obj_in={"consumed": latest_coupon.consumed + increment},
254
+ )