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.
- keble_payment/__init__.py +5 -0
- keble_payment/alipay/__init__.py +1 -0
- keble_payment/alipay/client.py +199 -0
- keble_payment/crud/__init__.py +14 -0
- keble_payment/crud/alipay.py +27 -0
- keble_payment/crud/bank_transaction.py +27 -0
- keble_payment/crud/coupon.py +254 -0
- keble_payment/crud/coupon_history.py +297 -0
- keble_payment/crud/order.py +20 -0
- keble_payment/crud/payment.py +35 -0
- keble_payment/crud/paypal_payout.py +416 -0
- keble_payment/crud/stripe_card.py +140 -0
- keble_payment/crud/stripe_checkout_session.py +247 -0
- keble_payment/crud/stripe_customer.py +135 -0
- keble_payment/crud/stripe_invoice.py +221 -0
- keble_payment/crud/stripe_payment_intent.py +252 -0
- keble_payment/crud/stripe_payment_method.py +180 -0
- keble_payment/crud/stripe_subscription.py +430 -0
- keble_payment/main/__init__.py +29 -0
- keble_payment/main/base.py +141 -0
- keble_payment/main/client.py +131 -0
- keble_payment/main/coupon.py +298 -0
- keble_payment/main/order.py +3252 -0
- keble_payment/main/paypal_webhook_handler.py +366 -0
- keble_payment/main/stripe_webhook_handler.py +348 -0
- keble_payment/main/utils.py +126 -0
- keble_payment/paypal/__init__.py +24 -0
- keble_payment/paypal/api/__init__.py +9 -0
- keble_payment/paypal/api/base.py +19 -0
- keble_payment/paypal/api/payout.py +163 -0
- keble_payment/paypal/client.py +361 -0
- keble_payment/paypal/config.py +54 -0
- keble_payment/paypal/enum.py +148 -0
- keble_payment/paypal/schemas/__init__.py +45 -0
- keble_payment/paypal/schemas/payout.py +603 -0
- keble_payment/paypal/schemas/webhook.py +21 -0
- keble_payment/paypal/utils.py +21 -0
- keble_payment/protocols.py +26 -0
- keble_payment/schemas/__init__.py +100 -0
- keble_payment/schemas/alipay.py +51 -0
- keble_payment/schemas/bank_transaction.py +26 -0
- keble_payment/schemas/base.py +15 -0
- keble_payment/schemas/coupon.py +182 -0
- keble_payment/schemas/coupon_attribution.py +378 -0
- keble_payment/schemas/coupon_history.py +40 -0
- keble_payment/schemas/enum.py +118 -0
- keble_payment/schemas/order.py +168 -0
- keble_payment/schemas/payment.py +34 -0
- keble_payment/schemas/paypal_payout.py +162 -0
- keble_payment/schemas/stripe_card.py +66 -0
- keble_payment/schemas/stripe_charge.py +89 -0
- keble_payment/schemas/stripe_checkout_session.py +99 -0
- keble_payment/schemas/stripe_customer.py +63 -0
- keble_payment/schemas/stripe_invoice.py +463 -0
- keble_payment/schemas/stripe_payment_intent.py +160 -0
- keble_payment/schemas/stripe_payment_method.py +70 -0
- keble_payment/schemas/stripe_subscription.py +168 -0
- keble_payment/stripe/__init__.py +1 -0
- keble_payment/stripe/api/__init__.py +45 -0
- keble_payment/stripe/api/base.py +41 -0
- keble_payment/stripe/api/billing_portal_session.py +37 -0
- keble_payment/stripe/api/charge.py +60 -0
- keble_payment/stripe/api/checkout_session.py +107 -0
- keble_payment/stripe/api/customer.py +92 -0
- keble_payment/stripe/api/event.py +43 -0
- keble_payment/stripe/api/invoice.py +174 -0
- keble_payment/stripe/api/payment_intent.py +190 -0
- keble_payment/stripe/api/payment_methods.py +200 -0
- keble_payment/stripe/api/portal_configuration.py +104 -0
- keble_payment/stripe/api/price.py +212 -0
- keble_payment/stripe/api/refund.py +98 -0
- keble_payment/stripe/api/setup_intent.py +182 -0
- keble_payment/stripe/api/subscription.py +222 -0
- keble_payment/stripe/api/subscription_schedule.py +192 -0
- keble_payment/stripe/api/transfer.py +118 -0
- keble_payment/stripe/config.py +21 -0
- keble_payment/stripe/enum.py +770 -0
- keble_payment/stripe/exceptions/__init__.py +1 -0
- keble_payment/stripe/exceptions/utils.py +2 -0
- keble_payment/stripe/schemas/__init__.py +255 -0
- keble_payment/stripe/schemas/address.py +27 -0
- keble_payment/stripe/schemas/amount.py +9 -0
- keble_payment/stripe/schemas/balance_transaction.py +91 -0
- keble_payment/stripe/schemas/billing.py +41 -0
- keble_payment/stripe/schemas/billing_portal_session.py +254 -0
- keble_payment/stripe/schemas/cash_balance.py +18 -0
- keble_payment/stripe/schemas/charge.py +390 -0
- keble_payment/stripe/schemas/checkout_session.py +1630 -0
- keble_payment/stripe/schemas/checks.py +10 -0
- keble_payment/stripe/schemas/coupon.py +131 -0
- keble_payment/stripe/schemas/customer.py +652 -0
- keble_payment/stripe/schemas/detailed.py +433 -0
- keble_payment/stripe/schemas/discount.py +49 -0
- keble_payment/stripe/schemas/dispute.py +45 -0
- keble_payment/stripe/schemas/event.py +62 -0
- keble_payment/stripe/schemas/evidence_detail.py +24 -0
- keble_payment/stripe/schemas/fee.py +19 -0
- keble_payment/stripe/schemas/invoice.py +642 -0
- keble_payment/stripe/schemas/out_come.py +13 -0
- keble_payment/stripe/schemas/payment_channels.py +224 -0
- keble_payment/stripe/schemas/payment_error.py +32 -0
- keble_payment/stripe/schemas/payment_intent.py +517 -0
- keble_payment/stripe/schemas/payment_method.py +177 -0
- keble_payment/stripe/schemas/portal_configuration.py +412 -0
- keble_payment/stripe/schemas/price.py +524 -0
- keble_payment/stripe/schemas/refund.py +150 -0
- keble_payment/stripe/schemas/setup_error.py +27 -0
- keble_payment/stripe/schemas/setup_intent.py +228 -0
- keble_payment/stripe/schemas/shipping.py +20 -0
- keble_payment/stripe/schemas/subscription.py +571 -0
- keble_payment/stripe/schemas/subscription_schedule.py +174 -0
- keble_payment/stripe/schemas/tip.py +8 -0
- keble_payment/stripe/schemas/transfer.py +136 -0
- keble_payment/stripe/schemas/transfer_data.py +18 -0
- keble_payment/stripe/schemas/transfer_reversal.py +26 -0
- keble_payment/stripe/schemas/utils.py +42 -0
- keble_payment/testing/__init__.py +6 -0
- keble_payment/testing/config.py +205 -0
- keble_payment/testing/helpers.py +17 -0
- keble_payment/utils.py +25 -0
- keble_payment-2.4.1.dist-info/METADATA +132 -0
- keble_payment-2.4.1.dist-info/RECORD +123 -0
- keble_payment-2.4.1.dist-info/WHEEL +4 -0
|
@@ -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
|
+
)
|