payment-kit 1.28.0 → 1.29.0
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/migrations/0004_iap_foundation.sql +72 -0
- package/cloudflare/migrations/0005_iap_tenant_backfill.sql +112 -0
- package/cloudflare/run-build.js +1 -0
- package/cloudflare/shims/blocklet-sdk/verify-session.ts +44 -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,415 @@
|
|
|
1
|
+
/* eslint-disable no-await-in-loop, no-continue --
|
|
2
|
+
Sequential per-subscription processing is intentional: each iteration
|
|
3
|
+
issues 1-2 D1 writes + an Apple/Google API call, and parallelizing would
|
|
4
|
+
blast D1 with N concurrent transactions and trip per-method rate limits
|
|
5
|
+
on Apple's App Store Server API. The `continue` guards bail early on
|
|
6
|
+
rows that can't be processed (missing tenant, missing original txn,
|
|
7
|
+
etc.) which is cleaner than nested if-else for a long per-row pipeline. */
|
|
8
|
+
// IAP reconcile cron — webhook backup.
|
|
9
|
+
//
|
|
10
|
+
// Webhooks are the fast path for App Store Server Notifications V2 and Google
|
|
11
|
+
// Play RTDN. They occasionally fail to deliver (Apple/Google outages, our 5xx
|
|
12
|
+
// during deploy, Pub/Sub backlog dropping old messages). When that happens the
|
|
13
|
+
// local Subscription row falls out of sync with the real subscription state at
|
|
14
|
+
// Apple/Google, and the user either keeps Pro after a refund or loses Pro at
|
|
15
|
+
// renewal.
|
|
16
|
+
//
|
|
17
|
+
// This module periodically pulls each active `app_store` / `google_play`
|
|
18
|
+
// subscription's authoritative state from the App Store Server API + Google
|
|
19
|
+
// Play Developer API and applies drift to the local row. Cron schedule is
|
|
20
|
+
// every 5 minutes by default; tuneable via `IAP_RECONCILE_CRON_TIME`.
|
|
21
|
+
|
|
22
|
+
import { Op } from 'sequelize';
|
|
23
|
+
|
|
24
|
+
import { createEvent } from '../libs/audit';
|
|
25
|
+
import logger from '../libs/logger';
|
|
26
|
+
import { PaymentMethod, Price, Subscription, SubscriptionItem } from '../store/models';
|
|
27
|
+
import { AppStoreClient, AppStoreTransactionPayload } from './app-store/client';
|
|
28
|
+
import { GooglePlayClient, GooglePlaySubscriptionPurchase } from './google-play/client';
|
|
29
|
+
|
|
30
|
+
/** Don't re-check subs that were updated by a webhook within the last 5 minutes. */
|
|
31
|
+
const RECENT_UPDATE_GUARD_MS = 5 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
/** Per-channel batch cap so a single cron tick can't stall on Apple/Google rate limits. */
|
|
34
|
+
const DEFAULT_BATCH_SIZE = Number(process.env.IAP_RECONCILE_BATCH_SIZE ?? '100');
|
|
35
|
+
|
|
36
|
+
type ReconcileStats = { checked: number; updated: number; errors: number };
|
|
37
|
+
|
|
38
|
+
const emptyStats = (): ReconcileStats => ({ checked: 0, updated: 0, errors: 0 });
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Single entry point used by the cron registry. Catches per-channel errors so
|
|
42
|
+
* one channel's outage doesn't take down the other.
|
|
43
|
+
*/
|
|
44
|
+
export async function runIapReconcile(): Promise<{ app_store: ReconcileStats; google_play: ReconcileStats }> {
|
|
45
|
+
const [appStore, googlePlay] = await Promise.all([
|
|
46
|
+
reconcileAppStore().catch((err) => {
|
|
47
|
+
logger.error('iap-reconcile: app_store channel failed', { error: err?.message });
|
|
48
|
+
return emptyStats();
|
|
49
|
+
}),
|
|
50
|
+
reconcileGooglePlay().catch((err) => {
|
|
51
|
+
logger.error('iap-reconcile: google_play channel failed', { error: err?.message });
|
|
52
|
+
return emptyStats();
|
|
53
|
+
}),
|
|
54
|
+
]);
|
|
55
|
+
// Self-heal SubscriptionItem rows for any IAP sub whose Product mapping
|
|
56
|
+
// became available AFTER purchase (typical: customer bought before admin
|
|
57
|
+
// bound the SKU to a local Product, so ingest skipped SubscriptionItem
|
|
58
|
+
// creation; once the binding exists, admin UI's product-name column and
|
|
59
|
+
// anything else that walks subscription.items would otherwise stay blank
|
|
60
|
+
// forever).
|
|
61
|
+
await backfillMissingSubscriptionItems().catch((err) => {
|
|
62
|
+
logger.error('iap-reconcile: subscription_items backfill failed', { error: err?.message });
|
|
63
|
+
});
|
|
64
|
+
logger.info('iap-reconcile: pass complete', { app_store: appStore, google_play: googlePlay });
|
|
65
|
+
return { app_store: appStore, google_play: googlePlay };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Backfill SubscriptionItem rows for IAP Subscriptions that lost out on the
|
|
70
|
+
* happy-path create (Price was unmapped at ingest time, or the create call
|
|
71
|
+
* crashed mid-flow). Idempotent — only inserts when both:
|
|
72
|
+
* - The sub has zero SubscriptionItem rows
|
|
73
|
+
* - We can resolve a Price via channel SKU → Price.metadata mapping
|
|
74
|
+
*
|
|
75
|
+
* Stripe-style schema: SKU binding lives on Price.metadata, not
|
|
76
|
+
* Product.metadata, so we point the SubscriptionItem at the exact Price the
|
|
77
|
+
* customer paid for (not the Product's default_price). That preserves the
|
|
78
|
+
* monthly-vs-yearly distinction in the SubscriptionItem record.
|
|
79
|
+
*/
|
|
80
|
+
async function backfillMissingSubscriptionItems(): Promise<void> {
|
|
81
|
+
const candidates = await Subscription.findAll({
|
|
82
|
+
where: { channel: { [Op.in]: ['google_play', 'app_store'] } as any },
|
|
83
|
+
limit: 500,
|
|
84
|
+
});
|
|
85
|
+
// Cache decoded PaymentMethod tenant identifiers (bundle_id / package_name)
|
|
86
|
+
// so we don't re-decrypt settings for every sub. Most installations have a
|
|
87
|
+
// small number of IAP PaymentMethods so this fits in a Map.
|
|
88
|
+
const tenantCache = new Map<string, string | null>();
|
|
89
|
+
const tenantFor = async (sub: Subscription): Promise<string | null> => {
|
|
90
|
+
const pmId = (sub as any).default_payment_method_id as string | undefined;
|
|
91
|
+
if (!pmId) return null;
|
|
92
|
+
if (tenantCache.has(pmId)) return tenantCache.get(pmId)!;
|
|
93
|
+
const pm = await PaymentMethod.findByPk(pmId);
|
|
94
|
+
const settings = pm ? PaymentMethod.decryptSettings(pm.settings) : null;
|
|
95
|
+
const tenant =
|
|
96
|
+
sub.channel === 'google_play'
|
|
97
|
+
? (settings?.google_play?.package_name ?? null)
|
|
98
|
+
: (settings?.app_store?.bundle_id ?? null);
|
|
99
|
+
tenantCache.set(pmId, tenant);
|
|
100
|
+
return tenant;
|
|
101
|
+
};
|
|
102
|
+
let inserted = 0;
|
|
103
|
+
for (const sub of candidates) {
|
|
104
|
+
const existing = await SubscriptionItem.count({ where: { subscription_id: sub.id } });
|
|
105
|
+
if (existing > 0) continue;
|
|
106
|
+
|
|
107
|
+
const pd: any = (sub as any).payment_details;
|
|
108
|
+
const sku = sub.channel === 'google_play' ? pd?.google_play?.product_id : pd?.app_store?.product_id;
|
|
109
|
+
if (!sku) continue;
|
|
110
|
+
|
|
111
|
+
// Multi-tenant scoping: filter Price lookup by the originating app's
|
|
112
|
+
// bundle_id (iOS) / package_name (Android). Without this, a sub from
|
|
113
|
+
// App A with SKU "pro_monthly" could be backfilled with App B's Price
|
|
114
|
+
// if App B also has a SKU "pro_monthly" — same SKU string lives in
|
|
115
|
+
// independent App Store / Play Console namespaces.
|
|
116
|
+
const tenant = await tenantFor(sub);
|
|
117
|
+
if (!tenant) {
|
|
118
|
+
logger.warn('iap-reconcile: no tenant id (bundleId/packageName) for sub, skipping backfill', {
|
|
119
|
+
subscriptionId: sub.id,
|
|
120
|
+
channel: sub.channel,
|
|
121
|
+
});
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const skuKey = sub.channel === 'google_play' ? 'google_play_product_id' : 'app_store_product_id';
|
|
125
|
+
const tenantKey = sub.channel === 'google_play' ? 'package_name' : 'bundle_id';
|
|
126
|
+
const price = await Price.findOne({
|
|
127
|
+
where: { [`metadata.${skuKey}`]: sku, [`metadata.${tenantKey}`]: tenant } as any,
|
|
128
|
+
});
|
|
129
|
+
if (!price) continue;
|
|
130
|
+
|
|
131
|
+
await SubscriptionItem.create({
|
|
132
|
+
subscription_id: sub.id,
|
|
133
|
+
price_id: price.id,
|
|
134
|
+
quantity: 1,
|
|
135
|
+
livemode: sub.livemode,
|
|
136
|
+
metadata: { backfilled_at: Math.floor(Date.now() / 1000), reason: 'reconcile_missing_item' },
|
|
137
|
+
} as any);
|
|
138
|
+
inserted += 1;
|
|
139
|
+
logger.info('iap-reconcile: backfilled SubscriptionItem', {
|
|
140
|
+
subscriptionId: sub.id,
|
|
141
|
+
productId: price.product_id,
|
|
142
|
+
priceId: price.id,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (inserted > 0) logger.info(`iap-reconcile: backfilled ${inserted} subscription_items`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// App Store
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
export async function reconcileAppStore(batchSize = DEFAULT_BATCH_SIZE): Promise<ReconcileStats> {
|
|
153
|
+
const stats = emptyStats();
|
|
154
|
+
const methods = await PaymentMethod.findAll({ where: { type: 'app_store' } });
|
|
155
|
+
if (methods.length === 0) return stats;
|
|
156
|
+
|
|
157
|
+
const subs = await Subscription.findAll({
|
|
158
|
+
where: {
|
|
159
|
+
status: { [Op.in]: ['active', 'past_due', 'trialing'] },
|
|
160
|
+
'payment_details.app_store.original_transaction_id': { [Op.ne]: null } as any,
|
|
161
|
+
updated_at: { [Op.lt]: new Date(Date.now() - RECENT_UPDATE_GUARD_MS) },
|
|
162
|
+
} as any,
|
|
163
|
+
order: [['updated_at', 'ASC']],
|
|
164
|
+
limit: batchSize,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
for (const sub of subs) {
|
|
168
|
+
stats.checked += 1;
|
|
169
|
+
try {
|
|
170
|
+
const originalTransactionId = sub.payment_details?.app_store?.original_transaction_id as string | undefined;
|
|
171
|
+
if (!originalTransactionId) continue;
|
|
172
|
+
|
|
173
|
+
const method = pickIapMethodForSub(methods, sub);
|
|
174
|
+
if (!method) {
|
|
175
|
+
logger.warn('iap-reconcile: no matching app_store PaymentMethod', { subscriptionId: sub.id });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Apple SDK throws when issuer/key creds aren't present. Skip cleanly.
|
|
180
|
+
let client: AppStoreClient;
|
|
181
|
+
try {
|
|
182
|
+
client = method.getAppStoreClient();
|
|
183
|
+
} catch (err: any) {
|
|
184
|
+
logger.warn('iap-reconcile: cannot build app_store client', { subscriptionId: sub.id, error: err?.message });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const transaction = await client.getSubscriptionStatus(originalTransactionId);
|
|
189
|
+
if (!transaction) {
|
|
190
|
+
logger.warn('iap-reconcile: app_store getSubscriptionStatus returned null', { originalTransactionId });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const drifted = await applyAppStoreTransactionDrift(sub, transaction);
|
|
194
|
+
if (drifted) stats.updated += 1;
|
|
195
|
+
} catch (err: any) {
|
|
196
|
+
stats.errors += 1;
|
|
197
|
+
logger.error('iap-reconcile: app_store sub failed', { subscriptionId: sub.id, error: err?.message });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return stats;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Select the IAP PaymentMethod that actually funds this subscription. Match by
|
|
205
|
+
* the bound `default_payment_method_id` — NOT the non-existent `payment_method_id`
|
|
206
|
+
* (the previous bug, which always fell through to methods[0] and queried the
|
|
207
|
+
* wrong app/credentials in multi-app/multi-env deployments — PR #1381 review P1).
|
|
208
|
+
* Only default to the sole method when there is exactly one; never pick an
|
|
209
|
+
* arbitrary method.
|
|
210
|
+
*/
|
|
211
|
+
export function pickIapMethodForSub(methods: PaymentMethod[], sub: Subscription): PaymentMethod | undefined {
|
|
212
|
+
const bound = methods.find((m) => m.id === sub.default_payment_method_id);
|
|
213
|
+
if (bound) return bound;
|
|
214
|
+
return methods.length === 1 ? methods[0] : undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Compare Apple's authoritative `transaction` payload against our local row
|
|
219
|
+
* and apply only the drifts that matter — period_end forward jump (renewed),
|
|
220
|
+
* revocation (refunded), explicit expiry.
|
|
221
|
+
*
|
|
222
|
+
* Returns true if we wrote to the row.
|
|
223
|
+
*/
|
|
224
|
+
export async function applyAppStoreTransactionDrift(
|
|
225
|
+
sub: Subscription,
|
|
226
|
+
transaction: AppStoreTransactionPayload
|
|
227
|
+
): Promise<boolean> {
|
|
228
|
+
const appleExpiresSec = transaction.expiresDate ? Math.floor(transaction.expiresDate / 1000) : undefined;
|
|
229
|
+
const revoked = Boolean(transaction.revocationDate);
|
|
230
|
+
|
|
231
|
+
if (revoked) {
|
|
232
|
+
if (sub.status === 'canceled') return false;
|
|
233
|
+
await sub.update({
|
|
234
|
+
status: 'canceled',
|
|
235
|
+
canceled_at: Math.floor((transaction.revocationDate ?? Date.now()) / 1000),
|
|
236
|
+
cancelation_details: {
|
|
237
|
+
reason: 'app_store_revoked',
|
|
238
|
+
feedback: `reconcile-cron: detected revocationDate=${transaction.revocationDate}`,
|
|
239
|
+
} as any,
|
|
240
|
+
metadata: {
|
|
241
|
+
...(sub.metadata || {}),
|
|
242
|
+
app_store_reconciled_at: Math.floor(Date.now() / 1000),
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
createEvent('Subscription', 'customer.subscription.deleted', sub).catch(() => {});
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Renewal drift — Apple says we have more time than we record.
|
|
250
|
+
if (appleExpiresSec && appleExpiresSec > sub.current_period_end + 60) {
|
|
251
|
+
await sub.update({
|
|
252
|
+
status: 'active',
|
|
253
|
+
current_period_end: appleExpiresSec,
|
|
254
|
+
payment_details: {
|
|
255
|
+
...(sub.payment_details || {}),
|
|
256
|
+
app_store: {
|
|
257
|
+
...(sub.payment_details?.app_store || ({} as any)),
|
|
258
|
+
expires_at: transaction.expiresDate ? Math.floor(transaction.expiresDate / 1000) : undefined,
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
metadata: {
|
|
262
|
+
...(sub.metadata || {}),
|
|
263
|
+
app_store_reconciled_at: Math.floor(Date.now() / 1000),
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
createEvent('Subscription', 'customer.subscription.updated', sub).catch(() => {});
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Past expiry — Apple says we're past period end but we still say active.
|
|
271
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
272
|
+
if (appleExpiresSec && appleExpiresSec < nowSec - 60 && (sub.status === 'active' || sub.status === 'trialing')) {
|
|
273
|
+
await sub.update({
|
|
274
|
+
status: 'canceled',
|
|
275
|
+
canceled_at: nowSec,
|
|
276
|
+
cancelation_details: {
|
|
277
|
+
reason: 'app_store_expired',
|
|
278
|
+
feedback: `reconcile-cron: appleExpires=${transaction.expiresDate} < now`,
|
|
279
|
+
} as any,
|
|
280
|
+
metadata: {
|
|
281
|
+
...(sub.metadata || {}),
|
|
282
|
+
app_store_reconciled_at: nowSec,
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
createEvent('Subscription', 'customer.subscription.deleted', sub).catch(() => {});
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// Google Play
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
export async function reconcileGooglePlay(batchSize = DEFAULT_BATCH_SIZE): Promise<ReconcileStats> {
|
|
297
|
+
const stats = emptyStats();
|
|
298
|
+
const methods = await PaymentMethod.findAll({ where: { type: 'google_play' } });
|
|
299
|
+
if (methods.length === 0) return stats;
|
|
300
|
+
|
|
301
|
+
const subs = await Subscription.findAll({
|
|
302
|
+
where: {
|
|
303
|
+
status: { [Op.in]: ['active', 'past_due', 'trialing'] },
|
|
304
|
+
'payment_details.google_play.purchase_token': { [Op.ne]: null } as any,
|
|
305
|
+
updated_at: { [Op.lt]: new Date(Date.now() - RECENT_UPDATE_GUARD_MS) },
|
|
306
|
+
} as any,
|
|
307
|
+
order: [['updated_at', 'ASC']],
|
|
308
|
+
limit: batchSize,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
for (const sub of subs) {
|
|
312
|
+
stats.checked += 1;
|
|
313
|
+
try {
|
|
314
|
+
const purchaseToken = sub.payment_details?.google_play?.purchase_token as string | undefined;
|
|
315
|
+
const productId = sub.payment_details?.google_play?.product_id as string | undefined;
|
|
316
|
+
if (!purchaseToken || !productId) continue;
|
|
317
|
+
|
|
318
|
+
const method = pickIapMethodForSub(methods, sub);
|
|
319
|
+
if (!method) {
|
|
320
|
+
logger.warn('iap-reconcile: no matching google_play PaymentMethod', { subscriptionId: sub.id });
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
let client: GooglePlayClient;
|
|
324
|
+
try {
|
|
325
|
+
client = method.getGooglePlayClient();
|
|
326
|
+
} catch (err: any) {
|
|
327
|
+
logger.warn('iap-reconcile: cannot build google_play client', { subscriptionId: sub.id, error: err?.message });
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const purchase = await client.getSubscription(productId, purchaseToken);
|
|
332
|
+
const drifted = await applyGooglePlayPurchaseDrift(sub, purchase);
|
|
333
|
+
if (drifted) stats.updated += 1;
|
|
334
|
+
} catch (err: any) {
|
|
335
|
+
stats.errors += 1;
|
|
336
|
+
logger.error('iap-reconcile: google_play sub failed', { subscriptionId: sub.id, error: err?.message });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return stats;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Compare Google's authoritative `purchase` payload against our local row
|
|
344
|
+
* and apply only meaningful drifts.
|
|
345
|
+
*
|
|
346
|
+
* Returns true if we wrote to the row.
|
|
347
|
+
*/
|
|
348
|
+
export async function applyGooglePlayPurchaseDrift(
|
|
349
|
+
sub: Subscription,
|
|
350
|
+
purchase: GooglePlaySubscriptionPurchase
|
|
351
|
+
): Promise<boolean> {
|
|
352
|
+
const expirySec = purchase.expiryTimeMillis ? Math.floor(Number(purchase.expiryTimeMillis) / 1000) : undefined;
|
|
353
|
+
// cancelReason: 0=user, 1=system, 2=replaced, 3=developer
|
|
354
|
+
const revoked =
|
|
355
|
+
typeof purchase.cancelReason === 'number' && purchase.cancelReason >= 0 && purchase.autoRenewing === false;
|
|
356
|
+
|
|
357
|
+
// Renewal drift forward
|
|
358
|
+
if (expirySec && expirySec > sub.current_period_end + 60) {
|
|
359
|
+
await sub.update({
|
|
360
|
+
status: 'active',
|
|
361
|
+
current_period_end: expirySec,
|
|
362
|
+
payment_details: {
|
|
363
|
+
...(sub.payment_details || {}),
|
|
364
|
+
google_play: {
|
|
365
|
+
...((sub.payment_details?.google_play || {}) as any),
|
|
366
|
+
expiry_time_millis: purchase.expiryTimeMillis,
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
metadata: {
|
|
370
|
+
...(sub.metadata || {}),
|
|
371
|
+
google_play_reconciled_at: Math.floor(Date.now() / 1000),
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
createEvent('Subscription', 'customer.subscription.updated', sub).catch(() => {});
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Auto-renew off (user canceled but still has paid period) → schedule cancel
|
|
379
|
+
if (revoked && !sub.cancel_at_period_end) {
|
|
380
|
+
await sub.update({
|
|
381
|
+
cancel_at_period_end: true,
|
|
382
|
+
cancelation_details: {
|
|
383
|
+
reason: 'google_play_auto_renew_off',
|
|
384
|
+
feedback: `reconcile-cron: cancelReason=${purchase.cancelReason} autoRenewing=false`,
|
|
385
|
+
} as any,
|
|
386
|
+
metadata: {
|
|
387
|
+
...(sub.metadata || {}),
|
|
388
|
+
google_play_reconciled_at: Math.floor(Date.now() / 1000),
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
createEvent('Subscription', 'customer.subscription.updated', sub).catch(() => {});
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Past expiry — Google says we're expired but local still active.
|
|
396
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
397
|
+
if (expirySec && expirySec < nowSec - 60 && (sub.status === 'active' || sub.status === 'trialing')) {
|
|
398
|
+
await sub.update({
|
|
399
|
+
status: 'canceled',
|
|
400
|
+
canceled_at: nowSec,
|
|
401
|
+
cancelation_details: {
|
|
402
|
+
reason: 'google_play_expired',
|
|
403
|
+
feedback: `reconcile-cron: googleExpires=${purchase.expiryTimeMillis} < now`,
|
|
404
|
+
} as any,
|
|
405
|
+
metadata: {
|
|
406
|
+
...(sub.metadata || {}),
|
|
407
|
+
google_play_reconciled_at: nowSec,
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
createEvent('Subscription', 'customer.subscription.deleted', sub).catch(() => {});
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return false;
|
|
415
|
+
}
|
package/api/src/libs/audit.ts
CHANGED
|
@@ -8,6 +8,29 @@ import { context } from './context';
|
|
|
8
8
|
|
|
9
9
|
const API_VERSION = '2023-09-05';
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Invoke every registered listener for `eventName` and await any Promise
|
|
13
|
+
* results. EventEmitter.emit() returns sync — listener async work would
|
|
14
|
+
* otherwise run as detached microtasks and die when the CF Workers handler
|
|
15
|
+
* unwinds. We want listeners (notably queues/event.ts's event.created
|
|
16
|
+
* handler that drives webhook delivery) to complete inside createEvent's
|
|
17
|
+
* Promise so waitUntil covers the whole chain.
|
|
18
|
+
*/
|
|
19
|
+
async function emitAndAwait(eventName: string, ...args: any[]): Promise<void> {
|
|
20
|
+
const listeners = events.rawListeners(eventName);
|
|
21
|
+
for (const listener of listeners) {
|
|
22
|
+
try {
|
|
23
|
+
const result = (listener as any)(...args);
|
|
24
|
+
if (result && typeof result.then === 'function') {
|
|
25
|
+
// eslint-disable-next-line no-await-in-loop -- sequential await keeps listener ordering deterministic for waitUntil chains
|
|
26
|
+
await result;
|
|
27
|
+
}
|
|
28
|
+
} catch (err: any) {
|
|
29
|
+
console.error('[audit emitAndAwait]', eventName, err?.message || err);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
export function createEvent(
|
|
12
35
|
scope: string,
|
|
13
36
|
type: LiteralUnion<EventType, string>,
|
|
@@ -58,8 +81,15 @@ async function doCreateEvent(scope: string, type: LiteralUnion<EventType, string
|
|
|
58
81
|
pending_webhooks: 99,
|
|
59
82
|
});
|
|
60
83
|
|
|
61
|
-
|
|
62
|
-
|
|
84
|
+
// Synchronously await listener chains so the HTTP handler's waitUntil
|
|
85
|
+
// scope covers the full handleEvent → addWebhookJob → push pipeline.
|
|
86
|
+
// EventEmitter.emit() returns sync and discards listener Promises — on
|
|
87
|
+
// CF Workers that leaves the listener microtask racing against worker
|
|
88
|
+
// termination, and `customer.subscription.started` / `.deleted` events
|
|
89
|
+
// fired at the tail of HTTP handlers were observed to lose their first
|
|
90
|
+
// delivery attempt because of it.
|
|
91
|
+
await emitAndAwait('event.created', { id: event.id });
|
|
92
|
+
await emitAndAwait(event.type, data.object, options);
|
|
63
93
|
}
|
|
64
94
|
|
|
65
95
|
export async function createStatusEvent(
|
|
@@ -99,8 +129,8 @@ export async function createStatusEvent(
|
|
|
99
129
|
pending_webhooks: 99,
|
|
100
130
|
});
|
|
101
131
|
|
|
102
|
-
|
|
103
|
-
|
|
132
|
+
await emitAndAwait('event.created', { id: event.id });
|
|
133
|
+
await emitAndAwait(event.type, data.object);
|
|
104
134
|
}
|
|
105
135
|
|
|
106
136
|
export async function createCustomEvent(
|
|
@@ -136,8 +166,8 @@ export async function createCustomEvent(
|
|
|
136
166
|
pending_webhooks: 99,
|
|
137
167
|
});
|
|
138
168
|
|
|
139
|
-
|
|
140
|
-
|
|
169
|
+
await emitAndAwait('event.created', { id: event.id });
|
|
170
|
+
await emitAndAwait(event.type, data.object);
|
|
141
171
|
}
|
|
142
172
|
|
|
143
173
|
/**
|
|
@@ -172,7 +202,7 @@ export async function createFlexibleEvent(
|
|
|
172
202
|
pending_webhooks: 99,
|
|
173
203
|
});
|
|
174
204
|
|
|
175
|
-
|
|
176
|
-
|
|
205
|
+
await emitAndAwait('event.created', { id: event.id });
|
|
206
|
+
await emitAndAwait(type, data);
|
|
177
207
|
return event;
|
|
178
208
|
}
|