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,565 @@
|
|
|
1
|
+
// Google Play Real-Time Developer Notification — subscription state machine.
|
|
2
|
+
//
|
|
3
|
+
// Maps each notificationType to the local Subscription state, calls
|
|
4
|
+
// `client.acknowledgeSubscription` for new purchases (3-day deadline), and
|
|
5
|
+
// emits the `customer.subscription.*` events so EntitlementGrant gets derived
|
|
6
|
+
// downstream — exactly the same events the Stripe handler emits.
|
|
7
|
+
|
|
8
|
+
import { Op } from 'sequelize';
|
|
9
|
+
|
|
10
|
+
import { createEvent } from '../../../libs/audit';
|
|
11
|
+
import logger from '../../../libs/logger';
|
|
12
|
+
import { Customer, PaymentMethod, Price, Subscription, SubscriptionItem } from '../../../store/models';
|
|
13
|
+
import { GooglePlayClient, GooglePlaySubscriptionPurchase } from '../client';
|
|
14
|
+
|
|
15
|
+
export const GooglePlayNotificationType = {
|
|
16
|
+
SUBSCRIPTION_RECOVERED: 1,
|
|
17
|
+
SUBSCRIPTION_RENEWED: 2,
|
|
18
|
+
SUBSCRIPTION_CANCELED: 3,
|
|
19
|
+
SUBSCRIPTION_PURCHASED: 4,
|
|
20
|
+
SUBSCRIPTION_ON_HOLD: 5,
|
|
21
|
+
SUBSCRIPTION_IN_GRACE_PERIOD: 6,
|
|
22
|
+
SUBSCRIPTION_RESTARTED: 7,
|
|
23
|
+
SUBSCRIPTION_PRICE_CHANGE_CONFIRMED: 8,
|
|
24
|
+
SUBSCRIPTION_DEFERRED: 9,
|
|
25
|
+
SUBSCRIPTION_PAUSED: 10,
|
|
26
|
+
SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED: 11,
|
|
27
|
+
SUBSCRIPTION_REVOKED: 12,
|
|
28
|
+
SUBSCRIPTION_EXPIRED: 13,
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
export type GooglePlaySubscriptionNotification = {
|
|
32
|
+
version: string;
|
|
33
|
+
notificationType: number;
|
|
34
|
+
purchaseToken: string;
|
|
35
|
+
subscriptionId: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Look up an existing local Subscription by Google Play purchase token,
|
|
40
|
+
* stored in `payment_details.google_play.purchase_token`.
|
|
41
|
+
*
|
|
42
|
+
* Prefers non-canceled rows so webhooks always target the live winner even if
|
|
43
|
+
* dedup has marked race-loser duplicates canceled with the same purchaseToken.
|
|
44
|
+
* Falls back to any row (oldest first) when nothing active remains — useful for
|
|
45
|
+
* post-expiry webhooks that should still find the row to log against.
|
|
46
|
+
*/
|
|
47
|
+
async function findSubscriptionByPurchaseToken(purchaseToken: string): Promise<Subscription | null> {
|
|
48
|
+
const active = await Subscription.findOne({
|
|
49
|
+
where: {
|
|
50
|
+
'payment_details.google_play.purchase_token': purchaseToken,
|
|
51
|
+
status: { [Op.notIn]: ['canceled', 'incomplete_expired'] },
|
|
52
|
+
} as any,
|
|
53
|
+
order: [['created_at', 'ASC']],
|
|
54
|
+
});
|
|
55
|
+
if (active) return active;
|
|
56
|
+
return Subscription.findOne({
|
|
57
|
+
where: {
|
|
58
|
+
'payment_details.google_play.purchase_token': purchaseToken,
|
|
59
|
+
} as any,
|
|
60
|
+
order: [['created_at', 'ASC']],
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Reconcile race-created duplicates for the same purchaseToken. Keeps the
|
|
66
|
+
* earliest-created active row, marks the rest canceled with a dedup marker so
|
|
67
|
+
* audit can trace why. Safe to call concurrently — converges to the same
|
|
68
|
+
* winner regardless of caller order.
|
|
69
|
+
*/
|
|
70
|
+
async function reconcilePurchaseTokenDuplicates(purchaseToken: string): Promise<Subscription> {
|
|
71
|
+
const live = await Subscription.findAll({
|
|
72
|
+
where: {
|
|
73
|
+
'payment_details.google_play.purchase_token': purchaseToken,
|
|
74
|
+
status: { [Op.notIn]: ['canceled', 'incomplete_expired'] },
|
|
75
|
+
} as any,
|
|
76
|
+
order: [['created_at', 'ASC']],
|
|
77
|
+
});
|
|
78
|
+
if (live.length === 0) {
|
|
79
|
+
throw new Error(`reconcile: no live subscription found for purchaseToken ${purchaseToken}`);
|
|
80
|
+
}
|
|
81
|
+
// Non-null: guarded by the `live.length === 0` throw above. TS doesn't narrow
|
|
82
|
+
// array index access, so assert explicitly.
|
|
83
|
+
const winner = live[0]!;
|
|
84
|
+
if (live.length === 1) return winner;
|
|
85
|
+
const now = Math.floor(Date.now() / 1000);
|
|
86
|
+
for (const dup of live.slice(1)) {
|
|
87
|
+
// eslint-disable-next-line no-await-in-loop -- sequential cancellation to avoid concurrent writes on the same sub set
|
|
88
|
+
await dup.update({
|
|
89
|
+
status: 'canceled',
|
|
90
|
+
canceled_at: now,
|
|
91
|
+
metadata: {
|
|
92
|
+
...(dup.metadata || {}),
|
|
93
|
+
dedup_replaced_by: winner.id,
|
|
94
|
+
dedup_reason: 'race_duplicate_on_verify',
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
logger.warn('google_play verify: race-duplicate Subscription marked canceled', {
|
|
98
|
+
duplicateId: dup.id,
|
|
99
|
+
winnerId: winner.id,
|
|
100
|
+
purchaseToken,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return winner;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Look up the Customer that owns this purchase, via the
|
|
108
|
+
* `obfuscatedExternalAccountId` set by the mobile SDK before purchase.
|
|
109
|
+
*/
|
|
110
|
+
function findCustomerByObfuscatedAccountId(uuid: string): Promise<Customer | null> {
|
|
111
|
+
return Customer.findOne({ where: { google_play_uuid: uuid } });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type HandleGooglePlayNotificationDeps = {
|
|
115
|
+
packageName: string;
|
|
116
|
+
client: GooglePlayClient;
|
|
117
|
+
notification: GooglePlaySubscriptionNotification;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Top-level dispatch for a subscriptionNotification payload.
|
|
122
|
+
*/
|
|
123
|
+
export async function handleGooglePlaySubscriptionEvent({
|
|
124
|
+
packageName,
|
|
125
|
+
client,
|
|
126
|
+
notification,
|
|
127
|
+
}: HandleGooglePlayNotificationDeps): Promise<void> {
|
|
128
|
+
const { notificationType, purchaseToken, subscriptionId } = notification;
|
|
129
|
+
|
|
130
|
+
logger.info('received google_play subscription notification', {
|
|
131
|
+
packageName,
|
|
132
|
+
notificationType,
|
|
133
|
+
subscriptionId,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
if (notificationType === GooglePlayNotificationType.SUBSCRIPTION_PURCHASED) {
|
|
137
|
+
await handlePurchased({ client, subscriptionId, purchaseToken });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const subscription = await findSubscriptionByPurchaseToken(purchaseToken);
|
|
142
|
+
if (!subscription) {
|
|
143
|
+
logger.warn('local subscription not found for google_play notification', {
|
|
144
|
+
notificationType,
|
|
145
|
+
purchaseToken,
|
|
146
|
+
subscriptionId,
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
switch (notificationType) {
|
|
152
|
+
case GooglePlayNotificationType.SUBSCRIPTION_RENEWED:
|
|
153
|
+
case GooglePlayNotificationType.SUBSCRIPTION_DEFERRED:
|
|
154
|
+
await handleRenewedOrDeferred({ client, subscription, subscriptionId, purchaseToken });
|
|
155
|
+
break;
|
|
156
|
+
|
|
157
|
+
case GooglePlayNotificationType.SUBSCRIPTION_RECOVERED:
|
|
158
|
+
case GooglePlayNotificationType.SUBSCRIPTION_RESTARTED:
|
|
159
|
+
await handleResumed(subscription);
|
|
160
|
+
break;
|
|
161
|
+
|
|
162
|
+
case GooglePlayNotificationType.SUBSCRIPTION_ON_HOLD:
|
|
163
|
+
case GooglePlayNotificationType.SUBSCRIPTION_IN_GRACE_PERIOD:
|
|
164
|
+
await markPastDue(subscription);
|
|
165
|
+
break;
|
|
166
|
+
|
|
167
|
+
case GooglePlayNotificationType.SUBSCRIPTION_PAUSED:
|
|
168
|
+
await markPaused(subscription);
|
|
169
|
+
break;
|
|
170
|
+
|
|
171
|
+
case GooglePlayNotificationType.SUBSCRIPTION_CANCELED:
|
|
172
|
+
await scheduleCancelAtPeriodEnd(subscription);
|
|
173
|
+
break;
|
|
174
|
+
|
|
175
|
+
case GooglePlayNotificationType.SUBSCRIPTION_EXPIRED:
|
|
176
|
+
await markExpired(subscription);
|
|
177
|
+
break;
|
|
178
|
+
|
|
179
|
+
case GooglePlayNotificationType.SUBSCRIPTION_REVOKED:
|
|
180
|
+
await handleRevoked(subscription);
|
|
181
|
+
break;
|
|
182
|
+
|
|
183
|
+
case GooglePlayNotificationType.SUBSCRIPTION_PRICE_CHANGE_CONFIRMED:
|
|
184
|
+
case GooglePlayNotificationType.SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED:
|
|
185
|
+
// Informational only — store latest payload but no state transition
|
|
186
|
+
await subscription.update({
|
|
187
|
+
metadata: {
|
|
188
|
+
...(subscription.metadata || {}),
|
|
189
|
+
google_play_last_notification_type: notificationType,
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
break;
|
|
193
|
+
|
|
194
|
+
default:
|
|
195
|
+
logger.warn('unhandled google_play notificationType', { notificationType });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function handlePurchased({
|
|
200
|
+
client,
|
|
201
|
+
subscriptionId,
|
|
202
|
+
purchaseToken,
|
|
203
|
+
}: {
|
|
204
|
+
client: GooglePlayClient;
|
|
205
|
+
subscriptionId: string;
|
|
206
|
+
purchaseToken: string;
|
|
207
|
+
}): Promise<void> {
|
|
208
|
+
// Critical: must call acknowledge within 3 days of SUBSCRIPTION_PURCHASED,
|
|
209
|
+
// otherwise Google auto-refunds. Do it before any other work.
|
|
210
|
+
try {
|
|
211
|
+
await client.acknowledgeSubscription(subscriptionId, purchaseToken);
|
|
212
|
+
logger.info('acknowledged google_play subscription', { subscriptionId, purchaseToken });
|
|
213
|
+
} catch (err) {
|
|
214
|
+
logger.error('failed to acknowledge google_play subscription', { err, subscriptionId, purchaseToken });
|
|
215
|
+
throw err;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const purchase = await client.getSubscription(subscriptionId, purchaseToken);
|
|
219
|
+
const customer = await resolveCustomer(purchase, purchaseToken);
|
|
220
|
+
if (!customer) {
|
|
221
|
+
logger.warn('orphan google_play purchase — no customer match; subscription not created', {
|
|
222
|
+
subscriptionId,
|
|
223
|
+
purchaseToken,
|
|
224
|
+
});
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// A2 mock phase: Subscription model wiring (Product/Price/SubscriptionItem
|
|
229
|
+
// mapping from Google productId → local price) lands with the resource sync
|
|
230
|
+
// commit. Here we only ensure ack happens and record the orphan-safety event.
|
|
231
|
+
logger.info('google_play purchase resolved to customer (Subscription creation deferred to resource sync)', {
|
|
232
|
+
customerId: customer.id,
|
|
233
|
+
subscriptionId,
|
|
234
|
+
purchaseToken,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function resolveCustomer(purchase: GooglePlaySubscriptionPurchase, purchaseToken: string): Promise<Customer | null> {
|
|
239
|
+
const uuid = purchase.obfuscatedExternalAccountId;
|
|
240
|
+
if (!uuid) {
|
|
241
|
+
logger.warn('google_play purchase has no obfuscatedExternalAccountId', { purchaseToken });
|
|
242
|
+
return Promise.resolve(null);
|
|
243
|
+
}
|
|
244
|
+
return findCustomerByObfuscatedAccountId(uuid);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function handleRenewedOrDeferred({
|
|
248
|
+
client,
|
|
249
|
+
subscription,
|
|
250
|
+
subscriptionId,
|
|
251
|
+
purchaseToken,
|
|
252
|
+
}: {
|
|
253
|
+
client: GooglePlayClient;
|
|
254
|
+
subscription: Subscription;
|
|
255
|
+
subscriptionId: string;
|
|
256
|
+
purchaseToken: string;
|
|
257
|
+
}): Promise<void> {
|
|
258
|
+
const purchase = await client.getSubscription(subscriptionId, purchaseToken);
|
|
259
|
+
const newExpiry = purchase.expiryTimeMillis ? Math.floor(Number(purchase.expiryTimeMillis) / 1000) : undefined;
|
|
260
|
+
await subscription.update({
|
|
261
|
+
status: 'active',
|
|
262
|
+
current_period_end: newExpiry ?? subscription.current_period_end,
|
|
263
|
+
payment_details: {
|
|
264
|
+
...(subscription.payment_details || {}),
|
|
265
|
+
google_play: {
|
|
266
|
+
...(subscription.payment_details?.google_play || { purchase_token: purchaseToken, product_id: subscriptionId }),
|
|
267
|
+
expiry_time_millis: purchase.expiryTimeMillis,
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
createEvent('Subscription', 'customer.subscription.started', subscription).catch(console.error);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function handleResumed(subscription: Subscription): Promise<void> {
|
|
275
|
+
await subscription.update({ status: 'active' });
|
|
276
|
+
createEvent('Subscription', 'customer.subscription.started', subscription).catch(console.error);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function markPastDue(subscription: Subscription): Promise<void> {
|
|
280
|
+
if (subscription.status === 'past_due') return;
|
|
281
|
+
await subscription.update({ status: 'past_due' });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function markPaused(subscription: Subscription): Promise<void> {
|
|
285
|
+
if (subscription.status === 'paused') return;
|
|
286
|
+
await subscription.update({ status: 'paused' });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function scheduleCancelAtPeriodEnd(subscription: Subscription): Promise<void> {
|
|
290
|
+
await subscription.update({ cancel_at_period_end: true });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function markExpired(subscription: Subscription): Promise<void> {
|
|
294
|
+
if (['canceled', 'incomplete_expired'].includes(subscription.status as string)) return;
|
|
295
|
+
await subscription.update({ status: 'canceled', canceled_at: Math.floor(Date.now() / 1000) });
|
|
296
|
+
createEvent('Subscription', 'customer.subscription.deleted', subscription).catch(console.error);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Client-initiated verify path (aistro-style).
|
|
301
|
+
*
|
|
302
|
+
* Mobile client posts the purchaseToken right after StoreKit / BillingClient
|
|
303
|
+
* finishes a purchase. We verify against Google, ACK, and either find the
|
|
304
|
+
* existing Subscription or create a new one. Idempotent — safe to call on
|
|
305
|
+
* client-side retries.
|
|
306
|
+
*/
|
|
307
|
+
export async function ingestVerifiedGooglePlayPurchase({
|
|
308
|
+
customerDid,
|
|
309
|
+
paymentMethod,
|
|
310
|
+
client,
|
|
311
|
+
purchaseToken,
|
|
312
|
+
subscriptionId,
|
|
313
|
+
}: {
|
|
314
|
+
customerDid: string;
|
|
315
|
+
paymentMethod: PaymentMethod;
|
|
316
|
+
client: GooglePlayClient;
|
|
317
|
+
purchaseToken: string;
|
|
318
|
+
subscriptionId: string;
|
|
319
|
+
}): Promise<{
|
|
320
|
+
subscription: Subscription;
|
|
321
|
+
isFirstSubscribe: boolean;
|
|
322
|
+
purchase: GooglePlaySubscriptionPurchase;
|
|
323
|
+
}> {
|
|
324
|
+
// 1. Verify with Google (this is the only path to confirm authenticity).
|
|
325
|
+
const purchase = await client.getSubscription(subscriptionId, purchaseToken);
|
|
326
|
+
|
|
327
|
+
// 2. Idempotency: if Subscription already exists for this token, return it.
|
|
328
|
+
const existing = await findSubscriptionByPurchaseToken(purchaseToken);
|
|
329
|
+
if (existing) {
|
|
330
|
+
// Best-effort: ACK if Google says not yet acknowledged.
|
|
331
|
+
if (purchase.acknowledgementState === 0) {
|
|
332
|
+
try {
|
|
333
|
+
await client.acknowledgeSubscription(subscriptionId, purchaseToken);
|
|
334
|
+
} catch (err) {
|
|
335
|
+
logger.error('google_play verify: ACK failed on existing subscription', { err });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// SKU drift sync — mirror of the app_store path. When the user
|
|
340
|
+
// crossgrades / changes plan inside the same purchase token (Google
|
|
341
|
+
// Play subscription product with multiple base plans), the
|
|
342
|
+
// subscriptionId Google returns differs from what we stored. Update
|
|
343
|
+
// payment_details + repoint SubscriptionItem at the new Price so
|
|
344
|
+
// entitlement.check reflects the new tier without waiting for the
|
|
345
|
+
// RTDN webhook.
|
|
346
|
+
const storedGoogleSku = (existing as any).payment_details?.google_play?.product_id;
|
|
347
|
+
if (storedGoogleSku && storedGoogleSku !== subscriptionId) {
|
|
348
|
+
// Multi-tenant scoping: filter by (sku, package_name) — two Android
|
|
349
|
+
// apps can have the same SKU string in their independent Play
|
|
350
|
+
// Console namespaces; package_name from the client is the tenant key.
|
|
351
|
+
const newPrice = await Price.findOne({
|
|
352
|
+
where: {
|
|
353
|
+
'metadata.google_play_product_id': subscriptionId,
|
|
354
|
+
'metadata.package_name': client.packageName,
|
|
355
|
+
} as any,
|
|
356
|
+
});
|
|
357
|
+
const newExpiresAt = purchase.expiryTimeMillis
|
|
358
|
+
? Math.floor(Number(purchase.expiryTimeMillis) / 1000)
|
|
359
|
+
: (existing.current_period_end ?? undefined);
|
|
360
|
+
const driftPatch: any = {
|
|
361
|
+
payment_details: {
|
|
362
|
+
...(existing.payment_details || {}),
|
|
363
|
+
google_play: {
|
|
364
|
+
...(existing.payment_details?.google_play || {}),
|
|
365
|
+
product_id: subscriptionId,
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
metadata: {
|
|
369
|
+
...(existing.metadata || {}),
|
|
370
|
+
google_play_product_id: subscriptionId,
|
|
371
|
+
product_id: newPrice?.product_id,
|
|
372
|
+
price_id: newPrice?.id,
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
if (newExpiresAt) driftPatch.current_period_end = newExpiresAt;
|
|
376
|
+
await existing.update(driftPatch);
|
|
377
|
+
if (newPrice) {
|
|
378
|
+
const items = await SubscriptionItem.findAll({ where: { subscription_id: existing.id } as any });
|
|
379
|
+
for (const item of items) {
|
|
380
|
+
if ((item as any).price_id !== newPrice.id) {
|
|
381
|
+
// eslint-disable-next-line no-await-in-loop -- sequential per-item updates to avoid concurrent writes on the same sub
|
|
382
|
+
await item.update({ price_id: newPrice.id });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
logger.info('google_play verify: SKU drift detected, synced to current Google-side product', {
|
|
387
|
+
subscriptionId: existing.id,
|
|
388
|
+
oldSku: storedGoogleSku,
|
|
389
|
+
newSku: subscriptionId,
|
|
390
|
+
newProductId: newPrice?.product_id,
|
|
391
|
+
newPriceId: newPrice?.id,
|
|
392
|
+
});
|
|
393
|
+
return { subscription: existing, isFirstSubscribe: false, purchase };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
logger.info('google_play verify: existing subscription, returning current state', {
|
|
397
|
+
subscriptionId: existing.id,
|
|
398
|
+
purchaseToken,
|
|
399
|
+
});
|
|
400
|
+
return { subscription: existing, isFirstSubscribe: false, purchase };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// 3. Refuse expired / unpurchased states.
|
|
404
|
+
if (purchase.paymentState !== undefined && purchase.paymentState !== 1) {
|
|
405
|
+
throw new Error(`google_play purchase paymentState=${purchase.paymentState} not active`);
|
|
406
|
+
}
|
|
407
|
+
const expiresAt = purchase.expiryTimeMillis ? Math.floor(Number(purchase.expiryTimeMillis) / 1000) : 0;
|
|
408
|
+
if (!expiresAt || expiresAt * 1000 < Date.now()) {
|
|
409
|
+
throw new Error('google_play purchase already expired');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// 4. findOrCreate Customer + persist UUID mapping (D-004) so future webhooks reverse-lookup works.
|
|
413
|
+
const [customer] = await Customer.findOrCreate({
|
|
414
|
+
where: { did: customerDid },
|
|
415
|
+
defaults: {
|
|
416
|
+
did: customerDid,
|
|
417
|
+
livemode: !!paymentMethod.livemode,
|
|
418
|
+
delinquent: false,
|
|
419
|
+
invoice_prefix: Customer.getInvoicePrefix(),
|
|
420
|
+
} as any,
|
|
421
|
+
});
|
|
422
|
+
if (purchase.obfuscatedExternalAccountId && customer.google_play_uuid !== purchase.obfuscatedExternalAccountId) {
|
|
423
|
+
await customer.update({ google_play_uuid: purchase.obfuscatedExternalAccountId });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// 5. Map Google subscriptionId → local Price (Stripe-style: SKU binding lives
|
|
427
|
+
// on Price.metadata, not Product.metadata, so one Product can have N
|
|
428
|
+
// Prices with N SKUs — monthly / yearly / promo all under the same tier).
|
|
429
|
+
// Multi-tenant scoping: filter by (sku, package_name) — two Android
|
|
430
|
+
// apps can have the same SKU string in their independent Play Console
|
|
431
|
+
// namespaces; package_name from the client is the tenant discriminator.
|
|
432
|
+
// If missing, we still create the Subscription so we don't lose data, but
|
|
433
|
+
// log loudly so ops can fix the binding.
|
|
434
|
+
const price = await Price.findOne({
|
|
435
|
+
where: {
|
|
436
|
+
'metadata.google_play_product_id': subscriptionId,
|
|
437
|
+
'metadata.package_name': client.packageName,
|
|
438
|
+
} as any,
|
|
439
|
+
});
|
|
440
|
+
if (!price) {
|
|
441
|
+
logger.warn('google_play verify: no local Price mapped to (subscriptionId, packageName)', {
|
|
442
|
+
subscriptionId,
|
|
443
|
+
packageName: client.packageName,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// 6. Is this the customer's first paid subscription on Google Play?
|
|
448
|
+
const isFirstSubscribe =
|
|
449
|
+
(await Subscription.count({
|
|
450
|
+
where: { customer_id: customer.id, channel: 'google_play' } as any,
|
|
451
|
+
})) === 0;
|
|
452
|
+
|
|
453
|
+
// 7. ACK before creating local rows — if ACK fails we want to know early.
|
|
454
|
+
if (purchase.acknowledgementState === 0) {
|
|
455
|
+
await client.acknowledgeSubscription(subscriptionId, purchaseToken);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// 8. Create the Subscription.
|
|
459
|
+
const now = Math.floor(Date.now() / 1000);
|
|
460
|
+
const subscription = await Subscription.create({
|
|
461
|
+
livemode: !!paymentMethod.livemode,
|
|
462
|
+
customer_id: customer.id,
|
|
463
|
+
currency_id: paymentMethod.default_currency_id ?? '',
|
|
464
|
+
default_payment_method_id: paymentMethod.id,
|
|
465
|
+
status: 'active',
|
|
466
|
+
channel: 'google_play',
|
|
467
|
+
environment: 'production',
|
|
468
|
+
current_period_start: purchase.startTimeMillis ? Math.floor(Number(purchase.startTimeMillis) / 1000) : now,
|
|
469
|
+
current_period_end: expiresAt,
|
|
470
|
+
cancel_at_period_end: false,
|
|
471
|
+
billing_cycle_anchor: now,
|
|
472
|
+
collection_method: 'charge_automatically',
|
|
473
|
+
start_date: now,
|
|
474
|
+
metadata: {
|
|
475
|
+
google_play_product_id: subscriptionId,
|
|
476
|
+
product_id: price?.product_id,
|
|
477
|
+
price_id: price?.id,
|
|
478
|
+
},
|
|
479
|
+
payment_details: {
|
|
480
|
+
google_play: {
|
|
481
|
+
purchase_token: purchaseToken,
|
|
482
|
+
product_id: subscriptionId,
|
|
483
|
+
// Multi-tenant: persist the verified package_name so cross-channel
|
|
484
|
+
// entitlement lookups can disambiguate same-SKU collisions across
|
|
485
|
+
// different Android apps wired into the same Payment Kit instance.
|
|
486
|
+
package_name: client.packageName,
|
|
487
|
+
order_id: purchase.orderId,
|
|
488
|
+
expiry_time_millis: purchase.expiryTimeMillis,
|
|
489
|
+
environment: 'production',
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
pending_invoice_item_interval: { interval: 'month', interval_count: 1 } as any,
|
|
493
|
+
} as any);
|
|
494
|
+
|
|
495
|
+
// 9. Create a SubscriptionItem pointing at the exact Price the customer
|
|
496
|
+
// paid for. Stripe-style: monthly vs yearly vs promo distinction is
|
|
497
|
+
// preserved here, not collapsed to the Product's default_price. Without
|
|
498
|
+
// the item, downstream lookups that walk Subscription → items → price →
|
|
499
|
+
// product (entitlement queries, invoice/notification templates that call
|
|
500
|
+
// getMainProductName, etc.) all render empty for IAP-channel subs.
|
|
501
|
+
if (price) {
|
|
502
|
+
await SubscriptionItem.create({
|
|
503
|
+
subscription_id: subscription.id,
|
|
504
|
+
price_id: price.id,
|
|
505
|
+
quantity: 1,
|
|
506
|
+
livemode: subscription.livemode,
|
|
507
|
+
metadata: {},
|
|
508
|
+
} as any);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// 10. Race-loser reconciliation. Concurrent /verify calls for the same
|
|
512
|
+
// purchaseToken (Google's BillingClient fires onPurchasesUpdated multiple
|
|
513
|
+
// times in some scenarios — network blips, BillingClient reconnect, retry
|
|
514
|
+
// after orphan ack) all pass the step-2 idempotency check before any of
|
|
515
|
+
// them commit a row. We can't acquire a row-level lock on D1, so the
|
|
516
|
+
// cheapest correct fix is post-create reconciliation: each writer scans
|
|
517
|
+
// for siblings, the earliest-created one wins, the rest mark themselves
|
|
518
|
+
// canceled so subsequent webhooks target only the winner.
|
|
519
|
+
const winner = await reconcilePurchaseTokenDuplicates(purchaseToken);
|
|
520
|
+
if (winner.id !== subscription.id) {
|
|
521
|
+
logger.info('google_play verify: lost create race, returning earliest sibling as winner', {
|
|
522
|
+
myId: subscription.id,
|
|
523
|
+
winnerId: winner.id,
|
|
524
|
+
purchaseToken,
|
|
525
|
+
});
|
|
526
|
+
return { subscription: winner, isFirstSubscribe: false, purchase };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
createEvent('Subscription', 'customer.subscription.started', subscription).catch(console.error);
|
|
530
|
+
logger.info('google_play verify: subscription created', {
|
|
531
|
+
subscriptionId: subscription.id,
|
|
532
|
+
customerId: customer.id,
|
|
533
|
+
productId: price?.product_id,
|
|
534
|
+
priceId: price?.id,
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
return { subscription, isFirstSubscribe, purchase };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function handleRevoked(subscription: Subscription): Promise<void> {
|
|
541
|
+
const now = Math.floor(Date.now() / 1000);
|
|
542
|
+
await subscription.update({
|
|
543
|
+
status: 'canceled',
|
|
544
|
+
ended_at: now,
|
|
545
|
+
cancelation_details: {
|
|
546
|
+
...(subscription.cancelation_details ?? { comment: '', feedback: 'other' }),
|
|
547
|
+
reason: 'cancellation_requested',
|
|
548
|
+
},
|
|
549
|
+
metadata: {
|
|
550
|
+
...(subscription.metadata || {}),
|
|
551
|
+
refunded: true,
|
|
552
|
+
refunded_at: now,
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// IAP refunds are mirrored as subscription state only — the canonical refund
|
|
557
|
+
// record lives at Google Play. We deliberately do NOT create a local Refund
|
|
558
|
+
// ledger row; user-facing refund/receipt history is in the Play Store.
|
|
559
|
+
// See docs/superpowers/specs/2026-06-04-iap-records-organization-design.md.
|
|
560
|
+
logger.info('google_play SUBSCRIPTION_REVOKED — status mirrored to canceled (refunded)', {
|
|
561
|
+
subscriptionId: subscription.id,
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
createEvent('Subscription', 'customer.subscription.deleted', subscription).catch(console.error);
|
|
565
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Voided Purchase Notification — fires when a purchase is refunded or chargeback'd.
|
|
2
|
+
// Distinct from `subscriptionNotification` (which carries SUBSCRIPTION_REVOKED only
|
|
3
|
+
// when access is also revoked alongside the refund). VoidedPurchase fires on the
|
|
4
|
+
// underlying order regardless of whether access is revoked.
|
|
5
|
+
//
|
|
6
|
+
// Payload (per Google docs):
|
|
7
|
+
// {
|
|
8
|
+
// purchaseToken: string,
|
|
9
|
+
// orderId: string,
|
|
10
|
+
// productType: 1 = ONE_TIME, 2 = SUBSCRIPTION,
|
|
11
|
+
// refundType: 1 = FULL, 2 = QUANTITY_BASED
|
|
12
|
+
// }
|
|
13
|
+
|
|
14
|
+
import { createEvent } from '../../../libs/audit';
|
|
15
|
+
import logger from '../../../libs/logger';
|
|
16
|
+
import { Subscription } from '../../../store/models';
|
|
17
|
+
|
|
18
|
+
export const GooglePlayVoidedProductType = {
|
|
19
|
+
ONE_TIME: 1,
|
|
20
|
+
SUBSCRIPTION: 2,
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
export const GooglePlayVoidedRefundType = {
|
|
24
|
+
FULL: 1,
|
|
25
|
+
QUANTITY_BASED: 2,
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
export type GooglePlayVoidedPurchaseNotification = {
|
|
29
|
+
purchaseToken: string;
|
|
30
|
+
orderId: string;
|
|
31
|
+
productType: number;
|
|
32
|
+
refundType: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export async function handleGooglePlayVoidedPurchase({
|
|
36
|
+
packageName,
|
|
37
|
+
notification,
|
|
38
|
+
}: {
|
|
39
|
+
packageName: string;
|
|
40
|
+
notification: GooglePlayVoidedPurchaseNotification;
|
|
41
|
+
}): Promise<void> {
|
|
42
|
+
const { purchaseToken, orderId, productType, refundType } = notification;
|
|
43
|
+
|
|
44
|
+
logger.info('received google_play voided-purchase notification', {
|
|
45
|
+
packageName,
|
|
46
|
+
orderId,
|
|
47
|
+
productType,
|
|
48
|
+
refundType,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (productType !== GooglePlayVoidedProductType.SUBSCRIPTION) {
|
|
52
|
+
// One-time product voids are out of scope here (the one-time IAP flow isn't
|
|
53
|
+
// wired yet). Log and skip so Pub/Sub still ACKs.
|
|
54
|
+
logger.info('google_play voided-purchase: ignoring non-subscription productType', {
|
|
55
|
+
productType,
|
|
56
|
+
orderId,
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const subscription = await Subscription.findOne({
|
|
62
|
+
where: {
|
|
63
|
+
'payment_details.google_play.purchase_token': purchaseToken,
|
|
64
|
+
} as any,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (!subscription) {
|
|
68
|
+
logger.warn('google_play voided-purchase: no local subscription matches purchaseToken', {
|
|
69
|
+
purchaseToken,
|
|
70
|
+
orderId,
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Terminate access. Idempotent — already-canceled subs are a no-op.
|
|
76
|
+
// We mirror handleRevoked's pattern from subscription.ts (which currently does
|
|
77
|
+
// the same minimal update + emits the same event; a future iteration should
|
|
78
|
+
// also create a Refund row, but that path needs payment_intent_id wiring for
|
|
79
|
+
// IAP channels which isn't done yet — see TODO below).
|
|
80
|
+
if (subscription.status !== 'canceled' && subscription.status !== 'incomplete_expired') {
|
|
81
|
+
await subscription.update({
|
|
82
|
+
status: 'canceled',
|
|
83
|
+
ended_at: Math.floor(Date.now() / 1000),
|
|
84
|
+
cancelation_details: {
|
|
85
|
+
...(subscription.cancelation_details ?? { comment: '', feedback: 'other' }),
|
|
86
|
+
reason: 'cancellation_requested',
|
|
87
|
+
},
|
|
88
|
+
metadata: {
|
|
89
|
+
...(subscription.metadata || {}),
|
|
90
|
+
google_play_voided_order_id: orderId,
|
|
91
|
+
google_play_voided_refund_type: refundType,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
createEvent('Subscription', 'customer.subscription.deleted', subscription).catch(console.error);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// TODO: create a Refund row for audit. Blocked on payment_intent_id schema —
|
|
98
|
+
// it's NOT NULL but we don't synthesize Stripe-style payment intents for IAP
|
|
99
|
+
// channel purchases. Either relax the column, or maintain a parallel
|
|
100
|
+
// 'platform_voided' record on Subscription.metadata only.
|
|
101
|
+
logger.info('google_play voided-purchase: subscription terminated (Refund row TBD)', {
|
|
102
|
+
subscriptionId: subscription.id,
|
|
103
|
+
orderId,
|
|
104
|
+
refundType,
|
|
105
|
+
});
|
|
106
|
+
}
|