payment-kit 1.28.0 → 1.29.1
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.
- package/api/src/crons/index.ts +22 -0
- package/api/src/crons/retry-pending-events.ts +58 -0
- package/api/src/integrations/app-store/apple-root-certs.ts +26 -0
- package/api/src/integrations/app-store/client.ts +369 -0
- package/api/src/integrations/app-store/handlers/index.ts +46 -0
- package/api/src/integrations/app-store/handlers/subscription.ts +635 -0
- package/api/src/integrations/app-store/node-apple-receipt-verify.d.ts +17 -0
- package/api/src/integrations/app-store/notification-routing.ts +18 -0
- package/api/src/integrations/app-store/signed-data-verifier.ts +150 -0
- package/api/src/integrations/google-play/client.ts +276 -0
- package/api/src/integrations/google-play/handlers/index.ts +69 -0
- package/api/src/integrations/google-play/handlers/subscription.ts +565 -0
- package/api/src/integrations/google-play/handlers/voided.ts +106 -0
- package/api/src/integrations/google-play/setup.ts +43 -0
- package/api/src/integrations/google-play/verify.ts +251 -0
- package/api/src/integrations/iap-reconcile.ts +415 -0
- package/api/src/libs/audit.ts +38 -8
- package/api/src/libs/entitlement.ts +399 -0
- package/api/src/libs/env.ts +2 -0
- package/api/src/libs/security.ts +51 -0
- package/api/src/libs/subscription.ts +13 -1
- package/api/src/libs/util.ts +13 -0
- package/api/src/queues/event.ts +25 -19
- package/api/src/queues/webhook.ts +12 -2
- package/api/src/routes/entitlements.ts +105 -0
- package/api/src/routes/events.ts +2 -2
- package/api/src/routes/index.ts +12 -2
- package/api/src/routes/integrations/app-store.ts +267 -0
- package/api/src/routes/integrations/google-play.ts +324 -0
- package/api/src/routes/payment-methods.ts +130 -0
- package/api/src/store/migrations/20260526-iap-foundation.ts +105 -0
- package/api/src/store/models/customer.ts +14 -0
- package/api/src/store/models/entitlement-grant.ts +118 -0
- package/api/src/store/models/entitlement-product.ts +48 -0
- package/api/src/store/models/entitlement.ts +86 -0
- package/api/src/store/models/index.ts +9 -0
- package/api/src/store/models/invoice.ts +20 -0
- package/api/src/store/models/payment-method.ts +62 -1
- package/api/src/store/models/refund.ts +10 -0
- package/api/src/store/models/subscription.ts +14 -0
- package/api/src/store/models/types.ts +32 -0
- package/api/tests/integrations/app-store/client.spec.ts +335 -0
- package/api/tests/integrations/app-store/handlers.spec.ts +480 -0
- package/api/tests/integrations/app-store/notifications.spec.ts +381 -0
- package/api/tests/integrations/app-store/signed-data-verifier.spec.ts +72 -0
- package/api/tests/integrations/app-store/webhook-routing.spec.ts +27 -0
- package/api/tests/integrations/google-play/handlers.spec.ts +341 -0
- package/api/tests/integrations/google-play/verify.spec.ts +215 -0
- package/api/tests/integrations/iap-reconcile.spec.ts +237 -0
- package/api/tests/libs/entitlement.spec.ts +347 -0
- package/blocklet.yml +1 -1
- package/cloudflare/docs/2026-06-10-bundle-size-analysis.md +288 -0
- package/cloudflare/migrations/0004_iap_foundation.sql +72 -0
- package/cloudflare/migrations/0005_iap_tenant_backfill.sql +112 -0
- package/cloudflare/run-build.js +23 -1
- package/cloudflare/shims/blocklet-sdk/verify-session.ts +44 -0
- package/cloudflare/shims/node-fetch.ts +35 -0
- package/cloudflare/shims/queue.ts +28 -2
- package/cloudflare/shims/sequelize-d1/model.ts +19 -0
- package/cloudflare/shims/sequelize-d1/operators.ts +14 -1
- package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +87 -0
- package/cloudflare/worker.ts +59 -4
- package/cloudflare/wrangler.jsonc +7 -1
- package/cloudflare/wrangler.staging.json +2 -1
- package/package.json +10 -6
- package/scripts/seed-google-play.ts +79 -0
- package/src/components/payment-method/app-store.tsx +103 -0
- package/src/components/payment-method/form.tsx +7 -1
- package/src/components/payment-method/google-play.tsx +85 -0
- package/src/components/subscription/list.tsx +20 -0
- package/src/locales/en.tsx +63 -0
- package/src/locales/zh.tsx +63 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +80 -0
- package/src/pages/admin/customers/customers/detail.tsx +6 -0
- package/src/pages/admin/settings/payment-methods/create.tsx +12 -0
- package/src/pages/admin/settings/payment-methods/index.tsx +1 -1
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// Cross-channel entitlement check.
|
|
2
|
+
//
|
|
3
|
+
// Goal: given a customer DID and a product_id, return whether the customer
|
|
4
|
+
// currently has the entitlement, regardless of which channel funded it
|
|
5
|
+
// (Stripe / on-chain / Google Play / App Store / one-time credit purchase).
|
|
6
|
+
//
|
|
7
|
+
// MVP scope (A3-MVP):
|
|
8
|
+
// - Subscription-funded entitlements are fully supported across all channels
|
|
9
|
+
// - One-time-purchase credit grants are supported when CreditGrant.metadata
|
|
10
|
+
// carries a `product_id` field (existing flows already do this for
|
|
11
|
+
// credit-grant-type Products)
|
|
12
|
+
// - When multiple subscriptions cover the same product (rare — e.g. user
|
|
13
|
+
// buys on iOS and on web), we pick by status priority (active > trialing
|
|
14
|
+
// > paused > past_due) and tie-break by latest current_period_end
|
|
15
|
+
//
|
|
16
|
+
// Channel inference:
|
|
17
|
+
// - Subscription.channel (A0 column) wins when set
|
|
18
|
+
// - Falls back to PaymentMethod.type — for legacy subscriptions written
|
|
19
|
+
// before A0, this maps stripe/arcblock/ethereum/base correctly
|
|
20
|
+
|
|
21
|
+
import { Op } from 'sequelize';
|
|
22
|
+
|
|
23
|
+
import { CreditGrant, Customer, PaymentMethod, Price, Product, Subscription, SubscriptionItem } from '../store/models';
|
|
24
|
+
|
|
25
|
+
// Subscriptions in `active`/`trialing` should still grant entitlement only
|
|
26
|
+
// while the current period hasn't elapsed. Without this, a row whose
|
|
27
|
+
// EXPIRED webhook never landed (network drop, RTDN race onto a duplicate
|
|
28
|
+
// row, etc.) stays status=active forever and the customer keeps Pro long
|
|
29
|
+
// after the platform has stopped billing them. `past_due`/`paused` are
|
|
30
|
+
// allowed to outrun current_period_end — those statuses are themselves
|
|
31
|
+
// the "should-have-renewed-but-hasn't" markers and the grace window is
|
|
32
|
+
// platform-controlled, not period-bounded.
|
|
33
|
+
const stillInActivePeriod = () => ({
|
|
34
|
+
[Op.or]: [
|
|
35
|
+
{ status: ['past_due', 'paused'] as any },
|
|
36
|
+
{
|
|
37
|
+
status: ['active', 'trialing'] as any,
|
|
38
|
+
current_period_end: { [Op.gt]: Math.floor(Date.now() / 1000) },
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export type Channel = 'stripe' | 'app_store' | 'google_play' | 'arcblock' | 'ethereum' | 'base' | 'bitcoin' | null;
|
|
44
|
+
|
|
45
|
+
export type EntitlementCheckResult = {
|
|
46
|
+
active: boolean;
|
|
47
|
+
channel: Channel;
|
|
48
|
+
expires_at: number | null;
|
|
49
|
+
subscription_id: string | null;
|
|
50
|
+
source: 'subscription' | 'one_time' | null;
|
|
51
|
+
credit_remaining?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const SUBSCRIPTION_STATUS_PRIORITY: Record<string, number> = {
|
|
55
|
+
active: 0,
|
|
56
|
+
trialing: 1,
|
|
57
|
+
paused: 2,
|
|
58
|
+
past_due: 3,
|
|
59
|
+
};
|
|
60
|
+
const ACTIVE_STATUSES = new Set(['active', 'trialing']);
|
|
61
|
+
|
|
62
|
+
function inactiveResult(): EntitlementCheckResult {
|
|
63
|
+
return { active: false, channel: null, expires_at: null, subscription_id: null, source: null };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function inferChannelFromSubscription(sub: Subscription): Promise<Channel> {
|
|
67
|
+
const ch = (sub as any).channel as Channel | undefined;
|
|
68
|
+
if (ch) return ch;
|
|
69
|
+
if (!sub.default_payment_method_id) return null;
|
|
70
|
+
const method = await PaymentMethod.findByPk(sub.default_payment_method_id);
|
|
71
|
+
return (method?.type as Channel) ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function inferChannelFromGrant(grant: CreditGrant): Promise<Channel> {
|
|
75
|
+
// Most CreditGrant rows store the originating payment_method_id in metadata.
|
|
76
|
+
const pmId = grant.metadata?.payment_method_id || grant.metadata?.paymentMethodId;
|
|
77
|
+
if (!pmId) return null;
|
|
78
|
+
const method = await PaymentMethod.findByPk(pmId);
|
|
79
|
+
return (method?.type as Channel) ?? null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function pickBestSubscription(subs: Subscription[]): Subscription | undefined {
|
|
83
|
+
return [...subs].sort((a, b) => {
|
|
84
|
+
const pa = SUBSCRIPTION_STATUS_PRIORITY[a.status as string] ?? 99;
|
|
85
|
+
const pb = SUBSCRIPTION_STATUS_PRIORITY[b.status as string] ?? 99;
|
|
86
|
+
if (pa !== pb) return pa - pb;
|
|
87
|
+
// tie-break: later expiry wins
|
|
88
|
+
return (b.current_period_end ?? 0) - (a.current_period_end ?? 0);
|
|
89
|
+
})[0];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The channel SKU stored on an IAP subscription (Apple/Google product id).
|
|
94
|
+
* Its presence marks a subscription whose product MUST be resolved live via the
|
|
95
|
+
* Product↔SKU mapping rather than via its (possibly stale) SubscriptionItem.
|
|
96
|
+
*/
|
|
97
|
+
function channelSkuOf(sub: any): string | undefined {
|
|
98
|
+
const pd = sub?.payment_details as
|
|
99
|
+
| { app_store?: { product_id?: string }; google_play?: { product_id?: string } }
|
|
100
|
+
| undefined;
|
|
101
|
+
return pd?.app_store?.product_id ?? pd?.google_play?.product_id;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve customer DID → Customer row; returns null if not found.
|
|
106
|
+
* Exposed so listEntitlements can reuse the same lookup.
|
|
107
|
+
*/
|
|
108
|
+
function resolveCustomer(customer_did: string, livemode: boolean): Promise<Customer | null> {
|
|
109
|
+
return Customer.findOne({ where: { did: customer_did, livemode } });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Find all active subscriptions that grant `product_id` to this customer.
|
|
114
|
+
* Walks each subscription's items → price → product to do the match.
|
|
115
|
+
*/
|
|
116
|
+
async function findSubscriptionsCoveringProduct(
|
|
117
|
+
customer: Customer,
|
|
118
|
+
productId: string,
|
|
119
|
+
livemode: boolean
|
|
120
|
+
): Promise<Subscription[]> {
|
|
121
|
+
const subs = await Subscription.findAll({
|
|
122
|
+
where: {
|
|
123
|
+
customer_id: customer.id,
|
|
124
|
+
livemode,
|
|
125
|
+
...stillInActivePeriod(),
|
|
126
|
+
} as any,
|
|
127
|
+
include: [
|
|
128
|
+
{
|
|
129
|
+
model: SubscriptionItem,
|
|
130
|
+
as: 'items',
|
|
131
|
+
include: [{ model: Price, as: 'price', include: [{ model: Product, as: 'product' }] }],
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
});
|
|
135
|
+
// Resolve the queried Product's channel SKUs from its Prices (Stripe-style:
|
|
136
|
+
// SKU binding lives on Price.metadata, not Product.metadata). One Product
|
|
137
|
+
// can have N Prices (monthly / yearly / promo), each bound to its own
|
|
138
|
+
// App Store SKU + Google Play SKU. Matching live against Prices means
|
|
139
|
+
// entitlement always reflects the bindings as configured NOW — does not
|
|
140
|
+
// depend on a product_id snapshot frozen into the subscription. A SKU
|
|
141
|
+
// rebind grants only the new product, not both (PR #1381 review P2).
|
|
142
|
+
//
|
|
143
|
+
// Multi-tenant scoping: the key includes the tenant (bundle_id /
|
|
144
|
+
// package_name) so a sub from App A doesn't accidentally satisfy a
|
|
145
|
+
// Price configured for App B that happens to share the SKU string —
|
|
146
|
+
// App Store / Play Console SKU namespaces are per-app, so two apps
|
|
147
|
+
// owning the literal string "pro_monthly" is the expected case.
|
|
148
|
+
const prices = await Price.findAll({ where: { product_id: productId, livemode } as any });
|
|
149
|
+
const appKeys = new Set<string>(); // ${bundle_id}:${sku}
|
|
150
|
+
const gpKeys = new Set<string>(); // ${package_name}:${sku}
|
|
151
|
+
for (const p of prices) {
|
|
152
|
+
const m = ((p as any).metadata as any) || {};
|
|
153
|
+
if (m.app_store_product_id && m.bundle_id) appKeys.add(`${m.bundle_id}:${m.app_store_product_id}`);
|
|
154
|
+
if (m.google_play_product_id && m.package_name) {
|
|
155
|
+
gpKeys.add(`${m.package_name}:${m.google_play_product_id}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return subs.filter((sub) => {
|
|
160
|
+
const pd = (sub as any).payment_details as
|
|
161
|
+
| {
|
|
162
|
+
app_store?: { product_id?: string; bundle_id?: string };
|
|
163
|
+
google_play?: { product_id?: string; package_name?: string };
|
|
164
|
+
}
|
|
165
|
+
| undefined;
|
|
166
|
+
|
|
167
|
+
// IAP subs that carry a channel SKU: resolve ONLY via the live channel-SKU ↔
|
|
168
|
+
// current Price metadata mapping. Deliberately ignore the (possibly stale)
|
|
169
|
+
// SubscriptionItem snapshot AND the legacy metadata.product_id — otherwise a
|
|
170
|
+
// SKU rebind would grant both the old item's product and the new mapping
|
|
171
|
+
// (PR #1381 review P2). This is the single authoritative rule for IAP, and
|
|
172
|
+
// listEntitlements applies the same mapping for consistency.
|
|
173
|
+
if (channelSkuOf(sub)) {
|
|
174
|
+
const appSku = pd?.app_store?.product_id;
|
|
175
|
+
const appBundle = pd?.app_store?.bundle_id;
|
|
176
|
+
const gpSku = pd?.google_play?.product_id;
|
|
177
|
+
const gpPkg = pd?.google_play?.package_name;
|
|
178
|
+
if (appSku && appBundle && appKeys.has(`${appBundle}:${appSku}`)) return true;
|
|
179
|
+
if (gpSku && gpPkg && gpKeys.has(`${gpPkg}:${gpSku}`)) return true;
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Stripe / legacy (no channel SKU stored): walk items → price → product,
|
|
184
|
+
// then the metadata snapshot.
|
|
185
|
+
const items = (sub as any).items as Array<{ price?: { product_id?: string } }> | undefined;
|
|
186
|
+
if (items?.some((it) => it.price?.product_id === productId)) return true;
|
|
187
|
+
return (sub.metadata as any)?.product_id === productId;
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Find a CreditGrant tied to this customer + product, if any.
|
|
193
|
+
* Convention: CreditGrant.metadata.product_id matches.
|
|
194
|
+
*/
|
|
195
|
+
async function findGrantCoveringProduct(customer: Customer, productId: string): Promise<CreditGrant | null> {
|
|
196
|
+
const grants = await CreditGrant.findAll({
|
|
197
|
+
where: { customer_id: customer.id, status: 'granted' as any },
|
|
198
|
+
});
|
|
199
|
+
return grants.find((g) => g.metadata?.product_id === productId) ?? null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isGrantActive(grant: CreditGrant): boolean {
|
|
203
|
+
if (grant.expires_at && grant.expires_at * 1000 < Date.now()) return false;
|
|
204
|
+
// remaining_amount is stored as string (BN-friendly). Treat anything ≠ "0" as positive.
|
|
205
|
+
const remaining = grant.remaining_amount ?? '0';
|
|
206
|
+
return remaining !== '0' && remaining !== '0.0';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Check whether the customer currently holds the entitlement for `product_id`.
|
|
211
|
+
*/
|
|
212
|
+
export async function checkEntitlement({
|
|
213
|
+
customer_did,
|
|
214
|
+
product_id,
|
|
215
|
+
livemode = true,
|
|
216
|
+
}: {
|
|
217
|
+
customer_did: string;
|
|
218
|
+
product_id: string;
|
|
219
|
+
livemode?: boolean;
|
|
220
|
+
}): Promise<EntitlementCheckResult> {
|
|
221
|
+
const customer = await resolveCustomer(customer_did, livemode);
|
|
222
|
+
if (!customer) return inactiveResult();
|
|
223
|
+
|
|
224
|
+
// 1. Subscription path
|
|
225
|
+
const matching = await findSubscriptionsCoveringProduct(customer, product_id, livemode);
|
|
226
|
+
const best = pickBestSubscription(matching);
|
|
227
|
+
if (best) {
|
|
228
|
+
return {
|
|
229
|
+
active: ACTIVE_STATUSES.has(best.status as string),
|
|
230
|
+
channel: await inferChannelFromSubscription(best),
|
|
231
|
+
expires_at: best.current_period_end ?? null,
|
|
232
|
+
subscription_id: best.id,
|
|
233
|
+
source: 'subscription',
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 2. One-time credit-grant path
|
|
238
|
+
const grant = await findGrantCoveringProduct(customer, product_id);
|
|
239
|
+
if (grant) {
|
|
240
|
+
return {
|
|
241
|
+
active: isGrantActive(grant),
|
|
242
|
+
channel: await inferChannelFromGrant(grant),
|
|
243
|
+
expires_at: grant.expires_at ?? null,
|
|
244
|
+
subscription_id: null,
|
|
245
|
+
source: 'one_time',
|
|
246
|
+
credit_remaining: grant.remaining_amount,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return inactiveResult();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export type EntitlementListItem = {
|
|
254
|
+
product_id: string;
|
|
255
|
+
active: boolean;
|
|
256
|
+
channel: Channel;
|
|
257
|
+
expires_at: number | null;
|
|
258
|
+
subscription_id: string | null;
|
|
259
|
+
source: 'subscription' | 'one_time';
|
|
260
|
+
credit_remaining?: string;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* List every entitlement this customer holds (one row per distinct product).
|
|
265
|
+
* Subscription items contribute first; CreditGrants add anything not already
|
|
266
|
+
* covered by a subscription.
|
|
267
|
+
*/
|
|
268
|
+
export async function listEntitlements({
|
|
269
|
+
customer_did,
|
|
270
|
+
livemode = true,
|
|
271
|
+
}: {
|
|
272
|
+
customer_did: string;
|
|
273
|
+
livemode?: boolean;
|
|
274
|
+
}): Promise<EntitlementListItem[]> {
|
|
275
|
+
const customer = await resolveCustomer(customer_did, livemode);
|
|
276
|
+
if (!customer) return [];
|
|
277
|
+
|
|
278
|
+
const subs = await Subscription.findAll({
|
|
279
|
+
where: {
|
|
280
|
+
customer_id: customer.id,
|
|
281
|
+
livemode,
|
|
282
|
+
...stillInActivePeriod(),
|
|
283
|
+
} as any,
|
|
284
|
+
include: [
|
|
285
|
+
{
|
|
286
|
+
model: SubscriptionItem,
|
|
287
|
+
as: 'items',
|
|
288
|
+
include: [{ model: Price, as: 'price', include: [{ model: Product, as: 'product' }] }],
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Group by product_id, picking best subscription per product. IAP subs that
|
|
294
|
+
// carry a channel SKU resolve via the live SKU→Product mapping (NOT their
|
|
295
|
+
// possibly-stale SubscriptionItem) so list agrees with checkEntitlement
|
|
296
|
+
// (PR #1381 review P2); everything else groups by items.
|
|
297
|
+
const productToSubs = new Map<string, Subscription[]>();
|
|
298
|
+
const addToGroup = (pid: string, sub: Subscription) => {
|
|
299
|
+
if (!productToSubs.has(pid)) productToSubs.set(pid, []);
|
|
300
|
+
productToSubs.get(pid)!.push(sub);
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const skuSubs = subs.filter((s) => channelSkuOf(s));
|
|
304
|
+
if (skuSubs.length) {
|
|
305
|
+
// Build (tenant, SKU)→productId from the Price catalogue. Stripe-style
|
|
306
|
+
// schema: Price.metadata.{app_store,google_play}_product_id binds the
|
|
307
|
+
// channel SKU to a specific Price; that Price's product_id is the
|
|
308
|
+
// entitlement key. One Product with N Prices (monthly / yearly / promo)
|
|
309
|
+
// all roll up to the same entitlement — exactly the behaviour
|
|
310
|
+
// `entitlements.check(productId)` expects on the client side.
|
|
311
|
+
//
|
|
312
|
+
// Multi-tenant scoping: same SKU string can live in independent App
|
|
313
|
+
// Store / Play Console namespaces (two iOS or two Android apps wired
|
|
314
|
+
// into one Payment Kit), so the key includes the tenant
|
|
315
|
+
// (bundle_id / package_name). Without this, Map.set would silently
|
|
316
|
+
// overwrite collisions and route all subs to whichever Price was
|
|
317
|
+
// iterated last. Each sub reads its own tenant from payment_details
|
|
318
|
+
// (persisted at create time, backfilled for legacy rows).
|
|
319
|
+
const prices = await Price.findAll({ where: { livemode } as any });
|
|
320
|
+
const appKeyToPid = new Map<string, string>();
|
|
321
|
+
const gpKeyToPid = new Map<string, string>();
|
|
322
|
+
for (const p of prices) {
|
|
323
|
+
const m = ((p as any).metadata as any) || {};
|
|
324
|
+
if (m.app_store_product_id && m.bundle_id) {
|
|
325
|
+
appKeyToPid.set(`${m.bundle_id}:${m.app_store_product_id}`, p.product_id);
|
|
326
|
+
}
|
|
327
|
+
if (m.google_play_product_id && m.package_name) {
|
|
328
|
+
gpKeyToPid.set(`${m.package_name}:${m.google_play_product_id}`, p.product_id);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
for (const sub of skuSubs) {
|
|
332
|
+
const pd = (sub as any).payment_details as
|
|
333
|
+
| {
|
|
334
|
+
app_store?: { product_id?: string; bundle_id?: string };
|
|
335
|
+
google_play?: { product_id?: string; package_name?: string };
|
|
336
|
+
}
|
|
337
|
+
| undefined;
|
|
338
|
+
const appSku = pd?.app_store?.product_id;
|
|
339
|
+
const appBundle = pd?.app_store?.bundle_id;
|
|
340
|
+
const gpSku = pd?.google_play?.product_id;
|
|
341
|
+
const gpPkg = pd?.google_play?.package_name;
|
|
342
|
+
const pid =
|
|
343
|
+
(appSku && appBundle && appKeyToPid.get(`${appBundle}:${appSku}`)) ||
|
|
344
|
+
(gpSku && gpPkg && gpKeyToPid.get(`${gpPkg}:${gpSku}`));
|
|
345
|
+
if (pid) addToGroup(pid, sub);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
for (const sub of subs) {
|
|
350
|
+
// eslint-disable-next-line no-continue -- IAP-with-SKU handled above via live mapping
|
|
351
|
+
if (channelSkuOf(sub)) continue;
|
|
352
|
+
const items = (sub as any).items as Array<{ price?: { product_id?: string } }> | undefined;
|
|
353
|
+
const productIds = new Set(items?.map((it) => it.price?.product_id).filter(Boolean) as string[]);
|
|
354
|
+
for (const pid of productIds) addToGroup(pid, sub);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const subscriptionProductIds = new Set<string>();
|
|
358
|
+
// Run channel inference in parallel — each is a Sequelize lookup, no shared state.
|
|
359
|
+
const subscriptionRows = await Promise.all(
|
|
360
|
+
Array.from(productToSubs.entries()).map(async ([productId, candidates]) => {
|
|
361
|
+
const best = pickBestSubscription(candidates)!;
|
|
362
|
+
subscriptionProductIds.add(productId);
|
|
363
|
+
const item: EntitlementListItem = {
|
|
364
|
+
product_id: productId,
|
|
365
|
+
active: ACTIVE_STATUSES.has(best.status as string),
|
|
366
|
+
channel: await inferChannelFromSubscription(best),
|
|
367
|
+
expires_at: best.current_period_end ?? null,
|
|
368
|
+
subscription_id: best.id,
|
|
369
|
+
source: 'subscription',
|
|
370
|
+
};
|
|
371
|
+
return item;
|
|
372
|
+
})
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
// Add CreditGrant-funded entitlements for products not already covered
|
|
376
|
+
const grants = await CreditGrant.findAll({
|
|
377
|
+
where: { customer_id: customer.id, status: 'granted' as any },
|
|
378
|
+
});
|
|
379
|
+
const uncoveredGrants = grants.filter((g) => {
|
|
380
|
+
const pid = g.metadata?.product_id;
|
|
381
|
+
return pid && !subscriptionProductIds.has(pid);
|
|
382
|
+
});
|
|
383
|
+
const grantRows = await Promise.all(
|
|
384
|
+
uncoveredGrants.map(async (grant) => {
|
|
385
|
+
const item: EntitlementListItem = {
|
|
386
|
+
product_id: grant.metadata!.product_id,
|
|
387
|
+
active: isGrantActive(grant),
|
|
388
|
+
channel: await inferChannelFromGrant(grant),
|
|
389
|
+
expires_at: grant.expires_at ?? null,
|
|
390
|
+
subscription_id: null,
|
|
391
|
+
source: 'one_time',
|
|
392
|
+
credit_remaining: grant.remaining_amount,
|
|
393
|
+
};
|
|
394
|
+
return item;
|
|
395
|
+
})
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
return [...subscriptionRows, ...grantRows];
|
|
399
|
+
}
|
package/api/src/libs/env.ts
CHANGED
|
@@ -18,6 +18,8 @@ export const depositVaultCronTime: string = process.env.DEPOSIT_VAULT_CRON_TIME
|
|
|
18
18
|
export const creditConsumptionCronTime: string = process.env.CREDIT_CONSUMPTION_CRON_TIME || '0 */10 * * * *'; // 默认每 10 min 执行一次
|
|
19
19
|
export const vendorStatusCheckCronTime: string = process.env.VENDOR_STATUS_CHECK_CRON_TIME || '0 */10 * * * *'; // 默认每 10 min 执行一次
|
|
20
20
|
export const vendorReturnScanCronTime: string = process.env.VENDOR_RETURN_SCAN_CRON_TIME || '0 */10 * * * *'; // 默认每 10 min 执行一次
|
|
21
|
+
export const iapReconcileCronTime: string = process.env.IAP_RECONCILE_CRON_TIME || '0 */5 * * * *'; // 默认每 5 min 执行一次:webhook 兜底,拉 App Store / Google Play 订阅最新状态
|
|
22
|
+
export const eventRetryCronTime: string = process.env.EVENT_RETRY_CRON_TIME || '30 */5 * * * *'; // 默认每 5 min 执行一次(错开整点避开 iap-reconcile):扫 pending_webhooks>0 的事件兜底投递
|
|
21
23
|
export const quoteCleanupCronTime: string = process.env.QUOTE_CLEANUP_CRON_TIME || '0 0 2 * * *'; // 默认每天凌晨 2 点执行一次
|
|
22
24
|
export const vendorTimeoutMinutes: number = process.env.VENDOR_TIMEOUT_MINUTES
|
|
23
25
|
? +process.env.VENDOR_TIMEOUT_MINUTES
|
package/api/src/libs/security.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { auth } from '@blocklet/sdk/lib/middlewares';
|
|
2
2
|
import { getVerifyData, verify } from '@blocklet/sdk/lib/util/verify-sign';
|
|
3
|
+
import { verifyLoginToken } from '@blocklet/sdk/lib/util/verify-session';
|
|
3
4
|
import { getWallet } from '@blocklet/sdk/lib/wallet';
|
|
4
5
|
import type { NextFunction, Request, Response } from 'express';
|
|
5
6
|
import type { Model } from 'sequelize';
|
|
@@ -39,6 +40,56 @@ export function authenticate<T extends Model>({
|
|
|
39
40
|
ensureLogin,
|
|
40
41
|
}: PermissionSpec<T>) {
|
|
41
42
|
return async (req: Request, res: Response, next: NextFunction) => {
|
|
43
|
+
// Dev-only bypass: requires both NODE_ENV=development AND the explicit
|
|
44
|
+
// opt-in env ENABLE_DEV_FAKE_AUTH=1, plus the x-dev-fake-did header on
|
|
45
|
+
// the request. Lets mobile demo clients that don't go through DID Connect
|
|
46
|
+
// (e.g. the local-tunnel backend which bypasses Blocklet Server) still
|
|
47
|
+
// exercise real handlers. Production never sets ENABLE_DEV_FAKE_AUTH, so
|
|
48
|
+
// this branch can't trigger there; dev defaults off, so we don't
|
|
49
|
+
// accidentally regress to fake auth after wiring the real flow.
|
|
50
|
+
if (process.env.NODE_ENV === 'development' && process.env.ENABLE_DEV_FAKE_AUTH === '1') {
|
|
51
|
+
const devDid = req.get('x-dev-fake-did');
|
|
52
|
+
if (devDid) {
|
|
53
|
+
req.user = {
|
|
54
|
+
did: devDid,
|
|
55
|
+
role: 'owner', // satisfies routes that require owner/admin
|
|
56
|
+
provider: 'dev',
|
|
57
|
+
fullName: 'dev-fake-user',
|
|
58
|
+
walletOS: '',
|
|
59
|
+
via: 'dev',
|
|
60
|
+
};
|
|
61
|
+
return next();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Authenticate by Authorization: Bearer <login-token>. The token is a JWT
|
|
66
|
+
// signed with this blocklet's session secret (see @blocklet/sdk session
|
|
67
|
+
// middleware). When clients hit us through a tunnel that bypasses Blocklet
|
|
68
|
+
// Server (so x-user-did is NOT injected), we need to verify the token
|
|
69
|
+
// ourselves. verifyLoginToken does local JWT signature verification, no
|
|
70
|
+
// HTTP callback. On success we forward into the existing x-user-did branch
|
|
71
|
+
// by populating req.headers so the role/mine/record cascade below applies
|
|
72
|
+
// unchanged.
|
|
73
|
+
const authHeader = req.get('authorization');
|
|
74
|
+
if (authHeader && /^Bearer\s+/i.test(authHeader) && !req.headers['x-user-did']) {
|
|
75
|
+
const token = authHeader.replace(/^Bearer\s+/i, '').trim();
|
|
76
|
+
if (token) {
|
|
77
|
+
const session = await verifyLoginToken({ token, strictMode: false }).catch(() => null);
|
|
78
|
+
if (session?.did) {
|
|
79
|
+
// Some BS versions put a bare base58 address in the JWT, others
|
|
80
|
+
// the canonical `did:abt:…` form. Normalize so downstream code
|
|
81
|
+
// (entitlement self-check, mine: lookups by req.user.did) sees the
|
|
82
|
+
// same shape clients send in `customer_did` query params.
|
|
83
|
+
const canonicalDid = session.did.startsWith('did:abt:') ? session.did : `did:abt:${session.did}`;
|
|
84
|
+
req.headers['x-user-did'] = canonicalDid;
|
|
85
|
+
req.headers['x-user-role'] = `blocklet-${session.role || 'user'}`;
|
|
86
|
+
req.headers['x-user-provider'] = session.provider || 'wallet';
|
|
87
|
+
req.headers['x-user-fullname'] = encodeURIComponent(session.fullName || '');
|
|
88
|
+
req.headers['x-user-wallet-os'] = session.walletOS || '';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
42
93
|
// authenticate by component call
|
|
43
94
|
const sig = req.get('x-component-sig');
|
|
44
95
|
if (component && sig) {
|
|
@@ -1357,8 +1357,20 @@ export async function isSubscriptionOverdraftProtectionEnabled(subscription: Sub
|
|
|
1357
1357
|
throw new Error(`PaymentMethod not found in ${subscription.id}`);
|
|
1358
1358
|
}
|
|
1359
1359
|
|
|
1360
|
+
// Overdraft protection is only meaningful for on-chain (arcblock) payment
|
|
1361
|
+
// methods where users stake tokens. IAP / Stripe channels have no notion of
|
|
1362
|
+
// staking; return disabled-default silently rather than throwing into the
|
|
1363
|
+
// outer catch (which would spam the error log on every google_play /
|
|
1364
|
+
// app_store / stripe subscription event).
|
|
1360
1365
|
if (paymentMethod.type !== 'arcblock') {
|
|
1361
|
-
|
|
1366
|
+
return {
|
|
1367
|
+
enabled: false,
|
|
1368
|
+
remaining: '0',
|
|
1369
|
+
used: '0',
|
|
1370
|
+
shouldPay: '0',
|
|
1371
|
+
unused: '0',
|
|
1372
|
+
revokedStake: '0',
|
|
1373
|
+
};
|
|
1362
1374
|
}
|
|
1363
1375
|
if (!subscription.overdraft_protection) {
|
|
1364
1376
|
return {
|
package/api/src/libs/util.ts
CHANGED
|
@@ -30,6 +30,19 @@ export const CHECKOUT_SESSION_TTL = 6 * 60 * 60; // expires in 6 hours, then rem
|
|
|
30
30
|
|
|
31
31
|
export const STRIPE_API_VERSION = '2023-08-16';
|
|
32
32
|
export const STRIPE_ENDPOINT: string = getUrl('/api/integrations/stripe/webhook');
|
|
33
|
+
// Pub/Sub OIDC tokens carry the push-subscription endpoint as `aud` claim. When
|
|
34
|
+
// the dev server is reached via a custom domain (e.g. a Cloudflare tunnel), the
|
|
35
|
+
// BLOCKLET_APP_URL-derived default won't match the URL Google signs against.
|
|
36
|
+
// Set GOOGLE_PLAY_WEBHOOK_URL to the externally-visible URL in that case.
|
|
37
|
+
// Lazy-eval (function not constant) because dotenv loads env AFTER this module
|
|
38
|
+
// is imported — a constant captured at module-load would only see BLOCKLET_APP_URL.
|
|
39
|
+
export const googlePlayEndpoint = (): string =>
|
|
40
|
+
process.env.GOOGLE_PLAY_WEBHOOK_URL || getUrl('/api/integrations/google-play/webhook');
|
|
41
|
+
|
|
42
|
+
// Back-compat constant for any caller that captures it at module-load.
|
|
43
|
+
// Prefer googlePlayEndpoint() going forward.
|
|
44
|
+
export const GOOGLE_PLAY_ENDPOINT: string = googlePlayEndpoint();
|
|
45
|
+
export const APP_STORE_ENDPOINT: string = getUrl('/api/integrations/app-store/webhook');
|
|
33
46
|
export const STRIPE_EVENTS: any[] = [
|
|
34
47
|
'checkout.session.async_payment_failed',
|
|
35
48
|
'checkout.session.async_payment_succeeded',
|
package/api/src/queues/event.ts
CHANGED
|
@@ -34,29 +34,35 @@ export const handleEvent = async (job: EventJob) => {
|
|
|
34
34
|
return;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
},
|
|
37
|
+
// Decide which endpoints still need a first attempt. The previous logic
|
|
38
|
+
// counted only SUCCESS attempts (2xx), so any permanent-failure endpoint
|
|
39
|
+
// (e.g. wrong URL → 404) would forever match attemptCount===0 and get
|
|
40
|
+
// re-pushed by the retry cron every minute, producing thousands of bogus
|
|
41
|
+
// attempts. We now count ANY attempt: webhookQueue has its own retry
|
|
42
|
+
// ladder (MAX_RETRY_COUNT=20) that owns recovery for transient failures,
|
|
43
|
+
// so re-pushing from this handler after the first attempt is double-work.
|
|
44
|
+
let stillPending = 0;
|
|
45
|
+
for (const webhook of eventWebhooks) {
|
|
46
|
+
// eslint-disable-next-line no-await-in-loop -- sequential per-webhook scheduling keeps D1 writes ordered
|
|
47
|
+
const anyAttempt = await WebhookAttempt.count({
|
|
48
|
+
where: { event_id: event.id, webhook_endpoint_id: webhook.id },
|
|
50
49
|
});
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (attemptCount === 0) {
|
|
50
|
+
if (anyAttempt === 0) {
|
|
51
|
+
stillPending += 1;
|
|
54
52
|
logger.info(`Scheduling attempt for event ${event.id} and webhook ${webhook.id}`, job);
|
|
55
|
-
|
|
53
|
+
// persist=true: write a D1 jobs row so CF Queue transport failures
|
|
54
|
+
// (momentary unavailability, consumer skip) don't silently lose the
|
|
55
|
+
// delivery — cron picks orphaned rows back up.
|
|
56
|
+
// eslint-disable-next-line no-await-in-loop -- same reason as above
|
|
57
|
+
await addWebhookJob(event.id, webhook.id, { persist: true });
|
|
56
58
|
}
|
|
57
|
-
}
|
|
59
|
+
}
|
|
58
60
|
|
|
59
|
-
|
|
61
|
+
// pending_webhooks reflects "endpoints that have not yet been attempted at
|
|
62
|
+
// all" — once all matched endpoints have any attempt row, the cron's
|
|
63
|
+
// pending>0 scan stops finding this event and it falls out of rotation.
|
|
64
|
+
await event.update({ pending_webhooks: stillPending });
|
|
65
|
+
logger.info(`Finished handling event ${job.eventId} (stillPending=${stillPending})`);
|
|
60
66
|
};
|
|
61
67
|
|
|
62
68
|
export const eventQueue = createQueue<EventJob>({
|
|
@@ -134,7 +134,12 @@ export const handleWebhook = async (job: WebhookJob) => {
|
|
|
134
134
|
process.nextTick(() => {
|
|
135
135
|
addWebhookJob(event.id, webhook.id, {
|
|
136
136
|
runAt: getNextRetry(retryCount, event.created_at),
|
|
137
|
-
persist:
|
|
137
|
+
// persist=true: on Cloudflare the queue shim only writes DELAYED jobs to
|
|
138
|
+
// D1 (with will_run_at, dispatched by the cron) when persisted, and does
|
|
139
|
+
// NOT send them to CF Queue. With persist:false the retry was silently
|
|
140
|
+
// dropped, so after the first failure the whole retry ladder vanished
|
|
141
|
+
// and the delivery was lost (PR #1381 review P1).
|
|
142
|
+
persist: true,
|
|
138
143
|
});
|
|
139
144
|
});
|
|
140
145
|
} else {
|
|
@@ -208,7 +213,12 @@ export async function addWebhookJob(
|
|
|
208
213
|
replace?: boolean;
|
|
209
214
|
} = {}
|
|
210
215
|
) {
|
|
211
|
-
|
|
216
|
+
// Default persist=true so CF Workers' Queue transport is backed by a D1
|
|
217
|
+
// jobs row — if CF Queue is momentarily unavailable or the consumer
|
|
218
|
+
// doesn't pick the message up, the row stays for the next cron dispatcher
|
|
219
|
+
// to retry. With persist=false the message can simply vanish (we lost
|
|
220
|
+
// ~13 minutes of webhook deliveries on staging confirming this).
|
|
221
|
+
const { runAt, persist = true, replace = true } = options;
|
|
212
222
|
const jobId = getWebhookJobId(eventId, webhookId);
|
|
213
223
|
const exist = await webhookQueue.get(jobId);
|
|
214
224
|
|