-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsubscriptions.py
More file actions
230 lines (197 loc) · 7.96 KB
/
Copy pathsubscriptions.py
File metadata and controls
230 lines (197 loc) · 7.96 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
"""CLINK Subscriptions: recurring charges via kind 21002 debit requests.
A subscription links a recurring :class:`~clink.models.Plan` to a payer's
static ``ndebit1...`` pointer. A permanent poller watches active
subscriptions and, whenever a billing period has elapsed, sends a kind 21002
direct-payment request to the payer's node service carrying a fresh BOLT11
invoice. On ``{"res": "ok"}`` the period is advanced and the attempt counter
resets; on failure the counter grows until the subscription is cancelled.
"""
from __future__ import annotations
import asyncio
import calendar
import json
from datetime import datetime, timedelta, timezone
from lnbits.core.services.payments import create_invoice
from loguru import logger
from .crud import get_active_subscriptions, get_plan, update_subscription
from .models import Subscription
from .nostr import (
decrypt_with_keys,
encrypt_with_keys,
generate_keypair,
verify_event,
)
from .nostr.bech32 import decode_ndebit
from .nostr.events import CLINK_VERSION_TAG, KIND_DEBIT_REQUEST, finalize
from .nostr.relay import request_response
from .pay import _relay_list
SUBSCRIPTIONS_TASK_NAME = "clink_subscriptions"
POLL_INTERVAL_SECONDS = 60
MAX_RENEWAL_ATTEMPTS = 3
RENEWAL_TIMEOUT_SECONDS = 30
class SubscriptionError(Exception):
"""Raised when a debit request cannot be sent or answered."""
def _utc(dt: datetime | None) -> datetime | None:
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def add_frequency(start: datetime, number: int, unit: str) -> datetime:
"""Add ``number`` of ``unit``s (day/week/month) to ``start``."""
if start.tzinfo is None:
start = start.replace(tzinfo=timezone.utc)
if unit == "day":
return start + timedelta(days=number)
if unit == "week":
return start + timedelta(weeks=number)
if unit == "month":
month = start.month - 1 + number
year = start.year + month // 12
month = month % 12 + 1
day = min(start.day, calendar.monthrange(year, month)[1])
return start.replace(year=year, month=month, day=day)
raise ValueError(f"unsupported frequency unit: {unit}")
def is_due(sub: Subscription, now: datetime | None = None) -> bool:
"""Whether an active subscription needs a renewal attempt."""
if sub.state != "active":
return False
end = _utc(sub.current_period_end)
if end is None:
return True
return end <= (now or datetime.now(timezone.utc))
def failed_renewal_state(attempts: int) -> str:
"""State after a failed renewal, based on attempts already recorded."""
return "cancelled" if attempts + 1 >= MAX_RENEWAL_ATTEMPTS else "active"
def build_debit_request(
decoded, amount_sats: int, bolt11: str, description: str | None = None
) -> tuple[dict, str]:
"""Build and sign a kind 21002 direct-payment request event.
Uses a fresh ephemeral keypair per attempt (as the CLINK SDK does) so
renewals are not linked to a persistent identity. Returns
``(event, payer_privkey)``.
"""
payer_privkey, _ = generate_keypair()
payload: dict = {"amount_sats": amount_sats, "bolt11": bolt11}
if decoded.pointer:
payload["pointer"] = decoded.pointer
if description:
payload["description"] = description
content = encrypt_with_keys(
json.dumps(payload, separators=(",", ":")), payer_privkey, decoded.pubkey
)
event = finalize(
payer_privkey,
KIND_DEBIT_REQUEST,
content,
[["p", decoded.pubkey], CLINK_VERSION_TAG],
)
return event, payer_privkey
def parse_debit_response(
event: dict, payer_privkey: str, recipient_pubkey: str
) -> dict:
"""Verify, decrypt and parse a kind 21002 response from the node service."""
if not verify_event(event):
raise SubscriptionError("Invalid response signature.")
if event.get("pubkey") != recipient_pubkey:
raise SubscriptionError("Response came from an unexpected pubkey.")
try:
content = event.get("content", "")
return json.loads(decrypt_with_keys(content, payer_privkey, recipient_pubkey))
except Exception as exc:
raise SubscriptionError(f"Cannot decrypt the debit response: {exc}") from exc
_inflight: set[str] = set()
async def renew_subscription(sub: Subscription) -> Subscription:
"""Attempt to bill one period of ``sub`` and advance it on success."""
if sub.id in _inflight:
return sub
_inflight.add(sub.id)
try:
return await _renew(sub)
finally:
_inflight.discard(sub.id)
async def _renew(sub: Subscription) -> Subscription:
plan = await get_plan(sub.plan_id)
if not plan or not plan.active:
return await _fail_subscription(sub, "Plan is inactive or missing.")
try:
decoded = decode_ndebit(sub.ndebit or "")
except ValueError as exc:
return await _fail_subscription(sub, f"Invalid ndebit: {exc}")
amount_sats = (plan.amount_msat or 0) // 1000
if amount_sats <= 0:
return await _fail_subscription(sub, "Plan amount must be at least 1 sat.")
try:
payment = await create_invoice(
wallet_id=sub.wallet,
amount=amount_sats,
memo=(plan.name or "CLINK subscription")[:640],
extension="clink",
extra={"clink": {"subscription_id": sub.id}},
external_id=sub.id,
)
except Exception as exc:
logger.warning(
f"clink: invoice creation failed for subscription {sub.id}: {exc}"
)
return await _fail_subscription(sub, f"Invoice creation failed: {exc}")
event, payer_privkey = build_debit_request(
decoded, amount_sats, payment.bolt11, plan.name
)
response_filter = {
"kinds": [KIND_DEBIT_REQUEST],
"#e": [event["id"]],
"#p": [event["pubkey"]],
}
relays = await _relay_list(decoded.relay)
try:
response_event = await request_response(
relays, event, response_filter, timeout=RENEWAL_TIMEOUT_SECONDS
)
except asyncio.TimeoutError:
return await _fail_subscription(
sub, "No response from the payer node (timeout)."
)
response = parse_debit_response(response_event, payer_privkey, decoded.pubkey)
if response.get("res") != "ok":
message = response.get("error") or "Payer node denied the debit request."
return await _fail_subscription(sub, message)
now = datetime.now(timezone.utc)
start = _utc(sub.current_period_end) or now
end = add_frequency(start, plan.frequency_number, plan.frequency_unit)
if end <= now:
start = now
end = add_frequency(now, plan.frequency_number, plan.frequency_unit)
sub.current_period_start = start
sub.current_period_end = end
sub.attempts = 0
sub.last_paid_at = now
sub.last_error = None
sub.state = "active"
sub.updated_at = now
await update_subscription(sub)
logger.info(f"clink: subscription {sub.id} renewed, next payment {end.isoformat()}")
return sub
async def _fail_subscription(sub: Subscription, error: str) -> Subscription:
sub.attempts += 1
sub.last_error = error
sub.updated_at = datetime.now(timezone.utc)
sub.state = failed_renewal_state(sub.attempts)
if sub.state == "cancelled":
logger.warning(
f"clink: subscription {sub.id} cancelled after {sub.attempts} "
f"failed renewals: {error}"
)
await update_subscription(sub)
return sub
async def clink_subscriptions() -> None:
"""Permanent task: poll due subscriptions and renew them."""
logger.info("clink: subscription poller started")
while True:
try:
for sub in await get_active_subscriptions():
if is_due(sub):
await renew_subscription(sub)
except Exception as exc: # pragma: no cover - defensive
logger.warning(f"clink: subscription poll error: {exc}")
await asyncio.sleep(POLL_INTERVAL_SECONDS)