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,635 @@
|
|
|
1
|
+
// App Store — subscription verification & ingest.
|
|
2
|
+
//
|
|
3
|
+
// A1 mock phase: only the client-initiated verify path is implemented (mirror
|
|
4
|
+
// of `integrations/google-play/handlers/subscription.ts`). Apple's S2S
|
|
5
|
+
// notifications (App Store Server Notifications V2) will land alongside A2's
|
|
6
|
+
// Pub/Sub webhook in a later commit.
|
|
7
|
+
//
|
|
8
|
+
// Idempotency: the stable key across renewals is `originalTransactionId` — Apple
|
|
9
|
+
// keeps it the same for every renewal of the same subscription, while
|
|
10
|
+
// `transactionId` changes per charge. We store both in payment_details for
|
|
11
|
+
// audit, but uniqueness/de-dup keys off originalTransactionId.
|
|
12
|
+
|
|
13
|
+
import { createEvent } from '../../../libs/audit';
|
|
14
|
+
import logger from '../../../libs/logger';
|
|
15
|
+
import { Customer, PaymentMethod, Price, Subscription, SubscriptionItem } from '../../../store/models';
|
|
16
|
+
import {
|
|
17
|
+
AppStoreClient,
|
|
18
|
+
AppStoreNotificationPayload,
|
|
19
|
+
AppStoreNotificationType,
|
|
20
|
+
AppStoreTransactionPayload,
|
|
21
|
+
} from '../client';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Thrown when a verify/restore presents an Apple subscription that already
|
|
25
|
+
* belongs to a different customer (DID). Apple subscriptions are NOT transferred
|
|
26
|
+
* between accounts — the client should prompt the user to sign in with the
|
|
27
|
+
* owning account. Mirrors OpenAI/Claude ("this subscription is associated with
|
|
28
|
+
* another account").
|
|
29
|
+
*/
|
|
30
|
+
export class AppStoreOwnershipMismatchError extends Error {
|
|
31
|
+
code = 'subscription_owned_by_another_account';
|
|
32
|
+
|
|
33
|
+
constructor(message: string) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'AppStoreOwnershipMismatchError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Look up an existing local Subscription by Apple's originalTransactionId,
|
|
41
|
+
* stored at `payment_details.app_store.original_transaction_id`. JSON path
|
|
42
|
+
* lookup is dialect-specific; matches the existing google_play pattern.
|
|
43
|
+
*/
|
|
44
|
+
function findSubscriptionByOriginalTransactionId(originalTransactionId: string): Promise<Subscription | null> {
|
|
45
|
+
return Subscription.findOne({
|
|
46
|
+
where: {
|
|
47
|
+
'payment_details.app_store.original_transaction_id': originalTransactionId,
|
|
48
|
+
} as any,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Client-initiated verify path (StoreKit 2 JWS).
|
|
54
|
+
*
|
|
55
|
+
* Mobile client posts the `jws_signed_transaction` right after
|
|
56
|
+
* `Product.purchase(...)` succeeds. We decode + verify the JWS (mock phase:
|
|
57
|
+
* decode only — see client.ts), then either return the existing Subscription
|
|
58
|
+
* or create a new one. Idempotent — safe to call on client-side retries.
|
|
59
|
+
*/
|
|
60
|
+
export async function ingestVerifiedAppStorePurchase({
|
|
61
|
+
customerDid,
|
|
62
|
+
paymentMethod,
|
|
63
|
+
client,
|
|
64
|
+
signedTransaction,
|
|
65
|
+
receipt,
|
|
66
|
+
expectedProductIds,
|
|
67
|
+
}: {
|
|
68
|
+
customerDid: string;
|
|
69
|
+
paymentMethod: PaymentMethod;
|
|
70
|
+
client: AppStoreClient;
|
|
71
|
+
/** StoreKit 2 JWS string (preferred when present) */
|
|
72
|
+
signedTransaction?: string;
|
|
73
|
+
/** StoreKit 1 legacy base64 receipt (fallback) */
|
|
74
|
+
receipt?: string;
|
|
75
|
+
/** Optional productId allowlist for legacy receipt filtering. */
|
|
76
|
+
expectedProductIds?: string[];
|
|
77
|
+
}): Promise<{
|
|
78
|
+
subscription: Subscription;
|
|
79
|
+
isFirstSubscribe: boolean;
|
|
80
|
+
transaction: AppStoreTransactionPayload;
|
|
81
|
+
}> {
|
|
82
|
+
if (!signedTransaction && !receipt) {
|
|
83
|
+
throw new Error('app_store verify: must provide signedTransaction or receipt');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 1. Verify via the right path. JWS wins if both are present (aistro pattern).
|
|
87
|
+
const transaction = signedTransaction
|
|
88
|
+
? await client.verifyJwsTransaction(signedTransaction)
|
|
89
|
+
: await client.verifyLegacyReceipt(receipt!, { expectedProductIds });
|
|
90
|
+
|
|
91
|
+
// 2. Idempotency: if Subscription already exists for this originalTransactionId.
|
|
92
|
+
const existing = await findSubscriptionByOriginalTransactionId(transaction.originalTransactionId);
|
|
93
|
+
if (existing) {
|
|
94
|
+
// Ownership guard (A+): an Apple subscription (one originalTransactionId /
|
|
95
|
+
// Apple account) is bound to the first DID that claimed it. If a DIFFERENT
|
|
96
|
+
// DID verifies/restores it, do NOT migrate — refuse so the client can prompt
|
|
97
|
+
// the user to sign in with the owning account. Subscriptions are not
|
|
98
|
+
// transferred between accounts. Mirrors OpenAI/Claude.
|
|
99
|
+
const owner = await Customer.findByPk(existing.customer_id);
|
|
100
|
+
if (owner && owner.did !== customerDid) {
|
|
101
|
+
logger.warn('app_store verify: subscription owned by a different DID — refusing', {
|
|
102
|
+
subscriptionId: existing.id,
|
|
103
|
+
ownerDid: owner.did,
|
|
104
|
+
requestDid: customerDid,
|
|
105
|
+
});
|
|
106
|
+
throw new AppStoreOwnershipMismatchError(
|
|
107
|
+
'This App Store subscription is linked to a different account. Please sign in with that account to use it.'
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const newExpiresAt = transaction.expiresDate ? Math.floor(Number(transaction.expiresDate) / 1000) : 0;
|
|
112
|
+
const newTxnValid = !!newExpiresAt && newExpiresAt * 1000 > Date.now();
|
|
113
|
+
const lapsed = ['canceled', 'incomplete_expired', 'past_due'].includes(existing.status as string);
|
|
114
|
+
// Sandbox re-subscribe / renewal arriving via client verify reuses the SAME
|
|
115
|
+
// originalTransactionId. If the stored sub had lapsed (expired→canceled) but
|
|
116
|
+
// the new transaction is valid, reactivate it instead of returning the stale
|
|
117
|
+
// canceled row — otherwise a re-purchase records nothing and the user stays
|
|
118
|
+
// "not subscribed". Active subs are returned untouched (the S2S webhook owns
|
|
119
|
+
// renewal bookkeeping for the active path).
|
|
120
|
+
if (lapsed && newTxnValid) {
|
|
121
|
+
const updatePatch: any = {
|
|
122
|
+
status: 'active',
|
|
123
|
+
current_period_end: newExpiresAt,
|
|
124
|
+
ended_at: null,
|
|
125
|
+
cancel_at_period_end: false,
|
|
126
|
+
payment_details: {
|
|
127
|
+
...(existing.payment_details || {}),
|
|
128
|
+
app_store: {
|
|
129
|
+
...(existing.payment_details?.app_store || {}),
|
|
130
|
+
transaction_id: transaction.transactionId,
|
|
131
|
+
expires_at: newExpiresAt,
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
// Self-heal: if the Product↔SKU mapping was added after the original
|
|
136
|
+
// purchase, metadata.product_id was frozen empty. Resolve it now so the
|
|
137
|
+
// admin can show the product name (entitlement already resolves live; this
|
|
138
|
+
// only fixes the stored display snapshot).
|
|
139
|
+
if (!(existing.metadata as any)?.product_id) {
|
|
140
|
+
// Multi-tenant scoping: filter by (sku, bundle_id) — two different
|
|
141
|
+
// iOS apps can have the same SKU string in their own App Store
|
|
142
|
+
// Connect namespaces; bundle_id from the JWS is the tenant key.
|
|
143
|
+
const mappedPrice = await Price.findOne({
|
|
144
|
+
where: {
|
|
145
|
+
'metadata.app_store_product_id': transaction.productId,
|
|
146
|
+
'metadata.bundle_id': transaction.bundleId,
|
|
147
|
+
} as any,
|
|
148
|
+
});
|
|
149
|
+
if (mappedPrice) {
|
|
150
|
+
updatePatch.metadata = {
|
|
151
|
+
...(existing.metadata || {}),
|
|
152
|
+
app_store_product_id: transaction.productId,
|
|
153
|
+
product_id: mappedPrice.product_id,
|
|
154
|
+
price_id: mappedPrice.id,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
await existing.update(updatePatch);
|
|
159
|
+
createEvent('Subscription', 'customer.subscription.started', existing).catch(console.error);
|
|
160
|
+
logger.info('app_store verify: reactivated lapsed subscription from fresh transaction', {
|
|
161
|
+
subscriptionId: existing.id,
|
|
162
|
+
originalTransactionId: transaction.originalTransactionId,
|
|
163
|
+
newExpiresAt,
|
|
164
|
+
});
|
|
165
|
+
return { subscription: existing, isFirstSubscribe: false, transaction };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// SKU drift sync — when the StoreKit-side product_id differs from
|
|
169
|
+
// what we have stored (Apple crossgrade / downgrade landed in
|
|
170
|
+
// Sandbox immediately; or the production DID_RENEW webhook hadn't
|
|
171
|
+
// landed yet and the client is forcing a re-verify), update our
|
|
172
|
+
// snapshot + repoint the SubscriptionItem at the new Price so
|
|
173
|
+
// entitlement.check reflects the new tier without waiting for the
|
|
174
|
+
// webhook. Without this, `purchase.restore()` after a tier change
|
|
175
|
+
// is a no-op — exactly the bug surfaced during real-device verify.
|
|
176
|
+
const storedAppStoreSku = (existing as any).payment_details?.app_store?.product_id;
|
|
177
|
+
if (storedAppStoreSku && storedAppStoreSku !== transaction.productId) {
|
|
178
|
+
const newPrice = await Price.findOne({
|
|
179
|
+
where: {
|
|
180
|
+
'metadata.app_store_product_id': transaction.productId,
|
|
181
|
+
'metadata.bundle_id': transaction.bundleId,
|
|
182
|
+
} as any,
|
|
183
|
+
});
|
|
184
|
+
const driftPatch: any = {
|
|
185
|
+
payment_details: {
|
|
186
|
+
...(existing.payment_details || {}),
|
|
187
|
+
app_store: {
|
|
188
|
+
...(existing.payment_details?.app_store || {}),
|
|
189
|
+
product_id: transaction.productId,
|
|
190
|
+
transaction_id: transaction.transactionId,
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
metadata: {
|
|
194
|
+
...(existing.metadata || {}),
|
|
195
|
+
app_store_product_id: transaction.productId,
|
|
196
|
+
product_id: newPrice?.product_id,
|
|
197
|
+
price_id: newPrice?.id,
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
if (newExpiresAt) driftPatch.current_period_end = newExpiresAt;
|
|
201
|
+
await existing.update(driftPatch);
|
|
202
|
+
|
|
203
|
+
// Repoint SubscriptionItem(s) at the new Price so admin views and
|
|
204
|
+
// any items→price→product walks see the right tier. Idempotent —
|
|
205
|
+
// hitting the same Price twice is a no-op update.
|
|
206
|
+
if (newPrice) {
|
|
207
|
+
const items = await SubscriptionItem.findAll({ where: { subscription_id: existing.id } as any });
|
|
208
|
+
for (const item of items) {
|
|
209
|
+
if ((item as any).price_id !== newPrice.id) {
|
|
210
|
+
// eslint-disable-next-line no-await-in-loop -- sequential per-item updates to avoid concurrent writes on the same sub
|
|
211
|
+
await item.update({ price_id: newPrice.id });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
logger.info('app_store verify: SKU drift detected, synced to current Apple-side product', {
|
|
216
|
+
subscriptionId: existing.id,
|
|
217
|
+
oldSku: storedAppStoreSku,
|
|
218
|
+
newSku: transaction.productId,
|
|
219
|
+
newProductId: newPrice?.product_id,
|
|
220
|
+
newPriceId: newPrice?.id,
|
|
221
|
+
});
|
|
222
|
+
return { subscription: existing, isFirstSubscribe: false, transaction };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
logger.info('app_store verify: existing subscription, returning current state', {
|
|
226
|
+
subscriptionId: existing.id,
|
|
227
|
+
originalTransactionId: transaction.originalTransactionId,
|
|
228
|
+
});
|
|
229
|
+
return { subscription: existing, isFirstSubscribe: false, transaction };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 3. Refuse expired subscriptions.
|
|
233
|
+
const expiresAt = transaction.expiresDate ? Math.floor(Number(transaction.expiresDate) / 1000) : 0;
|
|
234
|
+
if (!expiresAt || expiresAt * 1000 < Date.now()) {
|
|
235
|
+
throw new Error('app_store purchase already expired');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 4. findOrCreate Customer + persist appAccountToken so future S2S notifications can reverse-lookup.
|
|
239
|
+
const [customer] = await Customer.findOrCreate({
|
|
240
|
+
where: { did: customerDid },
|
|
241
|
+
defaults: {
|
|
242
|
+
did: customerDid,
|
|
243
|
+
livemode: !!paymentMethod.livemode,
|
|
244
|
+
delinquent: false,
|
|
245
|
+
invoice_prefix: Customer.getInvoicePrefix(),
|
|
246
|
+
} as any,
|
|
247
|
+
});
|
|
248
|
+
if (transaction.appAccountToken && customer.app_store_uuid !== transaction.appAccountToken) {
|
|
249
|
+
await customer.update({ app_store_uuid: transaction.appAccountToken });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 5. Map Apple productId → local Price (Stripe-style: SKU binding lives on
|
|
253
|
+
// Price.metadata, not Product.metadata, so one Product can have N Prices
|
|
254
|
+
// with N SKUs — monthly / yearly / promo all map under the same tier).
|
|
255
|
+
// Multi-tenant scoping: filter by (sku, bundle_id) — two iOS apps can
|
|
256
|
+
// have the same SKU string in their independent App Store Connect
|
|
257
|
+
// namespaces; bundle_id from the JWS is the tenant discriminator.
|
|
258
|
+
// Log but don't block if the mapping is missing; admin can wire it later
|
|
259
|
+
// and the entitlement layer resolves live (same policy as google_play).
|
|
260
|
+
const price = await Price.findOne({
|
|
261
|
+
where: {
|
|
262
|
+
'metadata.app_store_product_id': transaction.productId,
|
|
263
|
+
'metadata.bundle_id': transaction.bundleId,
|
|
264
|
+
} as any,
|
|
265
|
+
});
|
|
266
|
+
if (!price) {
|
|
267
|
+
logger.warn('app_store verify: no local Price mapped to (productId, bundleId)', {
|
|
268
|
+
productId: transaction.productId,
|
|
269
|
+
bundleId: transaction.bundleId,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 6. Is this the customer's first paid app_store subscription?
|
|
274
|
+
const isFirstSubscribe =
|
|
275
|
+
(await Subscription.count({
|
|
276
|
+
where: { customer_id: customer.id, channel: 'app_store' } as any,
|
|
277
|
+
})) === 0;
|
|
278
|
+
|
|
279
|
+
// 7. Create the Subscription.
|
|
280
|
+
const now = Math.floor(Date.now() / 1000);
|
|
281
|
+
const purchaseStartedAt = transaction.purchaseDate ? Math.floor(Number(transaction.purchaseDate) / 1000) : now;
|
|
282
|
+
const environment = transaction.environment === 'Production' ? 'production' : 'sandbox';
|
|
283
|
+
const subscription = await Subscription.create({
|
|
284
|
+
livemode: !!paymentMethod.livemode,
|
|
285
|
+
customer_id: customer.id,
|
|
286
|
+
currency_id: paymentMethod.default_currency_id ?? '',
|
|
287
|
+
default_payment_method_id: paymentMethod.id,
|
|
288
|
+
status: 'active',
|
|
289
|
+
channel: 'app_store',
|
|
290
|
+
environment,
|
|
291
|
+
current_period_start: purchaseStartedAt,
|
|
292
|
+
current_period_end: expiresAt,
|
|
293
|
+
cancel_at_period_end: false,
|
|
294
|
+
billing_cycle_anchor: now,
|
|
295
|
+
collection_method: 'charge_automatically',
|
|
296
|
+
start_date: now,
|
|
297
|
+
metadata: {
|
|
298
|
+
app_store_product_id: transaction.productId,
|
|
299
|
+
product_id: price?.product_id,
|
|
300
|
+
price_id: price?.id,
|
|
301
|
+
},
|
|
302
|
+
payment_details: {
|
|
303
|
+
app_store: {
|
|
304
|
+
original_transaction_id: transaction.originalTransactionId,
|
|
305
|
+
transaction_id: transaction.transactionId,
|
|
306
|
+
product_id: transaction.productId,
|
|
307
|
+
// Multi-tenant: persist the verified bundle_id so cross-channel
|
|
308
|
+
// entitlement lookups can disambiguate same-SKU collisions across
|
|
309
|
+
// different iOS apps wired into the same Payment Kit instance.
|
|
310
|
+
bundle_id: transaction.bundleId,
|
|
311
|
+
web_order_line_item_id: transaction.webOrderLineItemId,
|
|
312
|
+
environment: transaction.environment,
|
|
313
|
+
expires_at: expiresAt,
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
pending_invoice_item_interval: { interval: 'month', interval_count: 1 } as any,
|
|
317
|
+
} as any);
|
|
318
|
+
|
|
319
|
+
// 8. Create a SubscriptionItem pointing at the exact Price the customer
|
|
320
|
+
// paid for (monthly vs yearly vs promo distinction is preserved here,
|
|
321
|
+
// not collapsed to the Product's default_price). Without the item, the
|
|
322
|
+
// admin product column + any items→price→product walk (entitlement,
|
|
323
|
+
// invoice/notification templates) render empty for App Store subs.
|
|
324
|
+
if (price) {
|
|
325
|
+
await SubscriptionItem.create({
|
|
326
|
+
subscription_id: subscription.id,
|
|
327
|
+
price_id: price.id,
|
|
328
|
+
quantity: 1,
|
|
329
|
+
livemode: subscription.livemode,
|
|
330
|
+
metadata: {},
|
|
331
|
+
} as any);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
createEvent('Subscription', 'customer.subscription.started', subscription).catch(console.error);
|
|
335
|
+
logger.info('app_store verify: subscription created', {
|
|
336
|
+
subscriptionId: subscription.id,
|
|
337
|
+
customerId: customer.id,
|
|
338
|
+
productId: price?.product_id,
|
|
339
|
+
priceId: price?.id,
|
|
340
|
+
originalTransactionId: transaction.originalTransactionId,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
return { subscription, isFirstSubscribe, transaction };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Top-level dispatch for an App Store Server Notification V2 (already
|
|
348
|
+
* decoded by routes/integrations/app-store.ts). Mirrors google_play's
|
|
349
|
+
* handleGooglePlaySubscriptionEvent shape: map Apple `notificationType` to
|
|
350
|
+
* the local Subscription state machine and emit `customer.subscription.*`
|
|
351
|
+
* events so the downstream entitlement flow stays channel-agnostic.
|
|
352
|
+
*
|
|
353
|
+
* @param notification — outer signedPayload decoded
|
|
354
|
+
* @param transaction — inner signedTransactionInfo decoded by caller
|
|
355
|
+
* (we don't decode here because the caller already has
|
|
356
|
+
* the AppStoreClient and may want to apply additional
|
|
357
|
+
* validation before forwarding to the state machine).
|
|
358
|
+
*/
|
|
359
|
+
export async function handleAppStoreSubscriptionEvent({
|
|
360
|
+
notification,
|
|
361
|
+
transaction,
|
|
362
|
+
}: {
|
|
363
|
+
notification: AppStoreNotificationPayload;
|
|
364
|
+
transaction: AppStoreTransactionPayload;
|
|
365
|
+
}): Promise<void> {
|
|
366
|
+
const { notificationType, subtype, notificationUUID } = notification;
|
|
367
|
+
logger.info('received app_store notification', {
|
|
368
|
+
notificationType,
|
|
369
|
+
subtype,
|
|
370
|
+
notificationUUID,
|
|
371
|
+
originalTransactionId: transaction.originalTransactionId,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// SUBSCRIBED / OFFER_REDEEMED create the initial subscription; the others
|
|
375
|
+
// require an existing local Subscription to operate on.
|
|
376
|
+
if (notificationType === 'SUBSCRIBED' || notificationType === 'OFFER_REDEEMED') {
|
|
377
|
+
await handleAppStoreSubscribed({ transaction, environment: notification.data.environment });
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const subscription = await findSubscriptionByOriginalTransactionId(transaction.originalTransactionId);
|
|
382
|
+
if (!subscription) {
|
|
383
|
+
logger.warn('local subscription not found for app_store notification', {
|
|
384
|
+
notificationType,
|
|
385
|
+
originalTransactionId: transaction.originalTransactionId,
|
|
386
|
+
});
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
switch (notificationType as AppStoreNotificationType) {
|
|
391
|
+
case 'DID_RENEW':
|
|
392
|
+
await handleAppStoreRenewed(subscription, transaction);
|
|
393
|
+
break;
|
|
394
|
+
|
|
395
|
+
case 'EXPIRED':
|
|
396
|
+
case 'GRACE_PERIOD_EXPIRED':
|
|
397
|
+
await markAppStoreExpired(subscription);
|
|
398
|
+
break;
|
|
399
|
+
|
|
400
|
+
case 'DID_FAIL_TO_RENEW':
|
|
401
|
+
await markAppStorePastDue(subscription);
|
|
402
|
+
break;
|
|
403
|
+
|
|
404
|
+
case 'DID_CHANGE_RENEWAL_STATUS':
|
|
405
|
+
if (subtype === 'AUTO_RENEW_DISABLED') {
|
|
406
|
+
await scheduleAppStoreCancelAtPeriodEnd(subscription);
|
|
407
|
+
} else if (subtype === 'AUTO_RENEW_ENABLED') {
|
|
408
|
+
await unscheduleAppStoreCancel(subscription);
|
|
409
|
+
}
|
|
410
|
+
break;
|
|
411
|
+
|
|
412
|
+
case 'REVOKE':
|
|
413
|
+
case 'REFUND':
|
|
414
|
+
await handleAppStoreRevoked(subscription, notificationType, subtype);
|
|
415
|
+
break;
|
|
416
|
+
|
|
417
|
+
case 'REFUND_REVERSED':
|
|
418
|
+
// Apple reversed a previous refund. If we marked canceled because of the
|
|
419
|
+
// refund, we *should* reactivate — but only the human ops team can be
|
|
420
|
+
// sure right now. Log loudly and record metadata; reactivation is manual.
|
|
421
|
+
await subscription.update({
|
|
422
|
+
metadata: {
|
|
423
|
+
...(subscription.metadata || {}),
|
|
424
|
+
app_store_last_notification_type: notificationType,
|
|
425
|
+
app_store_refund_reversed_at: Math.floor(Date.now() / 1000),
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
logger.warn('app_store REFUND_REVERSED received — manual review recommended', {
|
|
429
|
+
subscriptionId: subscription.id,
|
|
430
|
+
});
|
|
431
|
+
break;
|
|
432
|
+
|
|
433
|
+
case 'PRICE_INCREASE':
|
|
434
|
+
case 'DID_CHANGE_RENEWAL_PREF':
|
|
435
|
+
case 'RENEWAL_EXTENDED':
|
|
436
|
+
case 'RENEWAL_EXTENSION':
|
|
437
|
+
case 'REFUND_DECLINED':
|
|
438
|
+
case 'CONSUMPTION_REQUEST':
|
|
439
|
+
// Informational only — record latest notification type but no state transition.
|
|
440
|
+
await subscription.update({
|
|
441
|
+
metadata: {
|
|
442
|
+
...(subscription.metadata || {}),
|
|
443
|
+
app_store_last_notification_type: notificationType,
|
|
444
|
+
app_store_last_notification_subtype: subtype,
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
break;
|
|
448
|
+
|
|
449
|
+
default:
|
|
450
|
+
logger.warn('unhandled app_store notificationType', { notificationType, subtype });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function handleAppStoreSubscribed({
|
|
455
|
+
transaction,
|
|
456
|
+
}: {
|
|
457
|
+
transaction: AppStoreTransactionPayload;
|
|
458
|
+
environment: 'Production' | 'Sandbox';
|
|
459
|
+
}): Promise<void> {
|
|
460
|
+
// Idempotency: if the Subscription already exists (e.g. client beat the
|
|
461
|
+
// webhook with /verify), there's nothing to do.
|
|
462
|
+
const existing = await findSubscriptionByOriginalTransactionId(transaction.originalTransactionId);
|
|
463
|
+
if (existing) {
|
|
464
|
+
logger.info('app_store SUBSCRIBED — local subscription already exists, no-op', {
|
|
465
|
+
subscriptionId: existing.id,
|
|
466
|
+
originalTransactionId: transaction.originalTransactionId,
|
|
467
|
+
});
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Try to map back to a Customer via appAccountToken (Apple's equivalent of
|
|
472
|
+
// obfuscatedExternalAccountId). Webhook-first purchases without a prior
|
|
473
|
+
// client verify happen on family-share, restore-purchase, and TestFlight
|
|
474
|
+
// paths — log loudly so ops can chase the orphan.
|
|
475
|
+
if (!transaction.appAccountToken) {
|
|
476
|
+
logger.warn('app_store SUBSCRIBED with no appAccountToken — orphan purchase, cannot map to customer', {
|
|
477
|
+
originalTransactionId: transaction.originalTransactionId,
|
|
478
|
+
productId: transaction.productId,
|
|
479
|
+
});
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const customer = await Customer.findOne({ where: { app_store_uuid: transaction.appAccountToken } });
|
|
483
|
+
if (!customer) {
|
|
484
|
+
logger.warn('app_store SUBSCRIBED — no Customer matches appAccountToken', {
|
|
485
|
+
appAccountToken: transaction.appAccountToken,
|
|
486
|
+
originalTransactionId: transaction.originalTransactionId,
|
|
487
|
+
});
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Find the app_store PaymentMethod for this livemode (assume one per livemode,
|
|
492
|
+
// mirrors the route-level constraint).
|
|
493
|
+
const paymentMethod = await PaymentMethod.findOne({
|
|
494
|
+
where: { type: 'app_store', active: true, livemode: customer.livemode },
|
|
495
|
+
});
|
|
496
|
+
if (!paymentMethod) {
|
|
497
|
+
logger.error('app_store SUBSCRIBED — no active PaymentMethod, skipping Subscription create');
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Multi-tenant: scope the SKU lookup to this app's bundle_id so a
|
|
502
|
+
// collision (two apps with same SKU string) routes to the right Price.
|
|
503
|
+
const price = await Price.findOne({
|
|
504
|
+
where: {
|
|
505
|
+
'metadata.app_store_product_id': transaction.productId,
|
|
506
|
+
'metadata.bundle_id': transaction.bundleId,
|
|
507
|
+
} as any,
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const now = Math.floor(Date.now() / 1000);
|
|
511
|
+
const expiresAt = transaction.expiresDate ? Math.floor(Number(transaction.expiresDate) / 1000) : 0;
|
|
512
|
+
const purchaseStartedAt = transaction.purchaseDate ? Math.floor(Number(transaction.purchaseDate) / 1000) : now;
|
|
513
|
+
const subscription = await Subscription.create({
|
|
514
|
+
livemode: customer.livemode,
|
|
515
|
+
customer_id: customer.id,
|
|
516
|
+
currency_id: paymentMethod.default_currency_id ?? '',
|
|
517
|
+
default_payment_method_id: paymentMethod.id,
|
|
518
|
+
status: 'active',
|
|
519
|
+
channel: 'app_store',
|
|
520
|
+
environment: transaction.environment === 'Production' ? 'production' : 'sandbox',
|
|
521
|
+
current_period_start: purchaseStartedAt,
|
|
522
|
+
current_period_end: expiresAt || now,
|
|
523
|
+
cancel_at_period_end: false,
|
|
524
|
+
billing_cycle_anchor: now,
|
|
525
|
+
collection_method: 'charge_automatically',
|
|
526
|
+
start_date: now,
|
|
527
|
+
metadata: {
|
|
528
|
+
app_store_product_id: transaction.productId,
|
|
529
|
+
product_id: price?.product_id,
|
|
530
|
+
price_id: price?.id,
|
|
531
|
+
},
|
|
532
|
+
payment_details: {
|
|
533
|
+
app_store: {
|
|
534
|
+
original_transaction_id: transaction.originalTransactionId,
|
|
535
|
+
transaction_id: transaction.transactionId,
|
|
536
|
+
product_id: transaction.productId,
|
|
537
|
+
// Multi-tenant: persist the verified bundle_id (same rationale as
|
|
538
|
+
// the client-verify path above).
|
|
539
|
+
bundle_id: transaction.bundleId,
|
|
540
|
+
web_order_line_item_id: transaction.webOrderLineItemId,
|
|
541
|
+
environment: transaction.environment,
|
|
542
|
+
expires_at: expiresAt,
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
pending_invoice_item_interval: { interval: 'month', interval_count: 1 } as any,
|
|
546
|
+
} as any);
|
|
547
|
+
|
|
548
|
+
createEvent('Subscription', 'customer.subscription.started', subscription).catch(console.error);
|
|
549
|
+
logger.info('app_store SUBSCRIBED — subscription created from webhook', {
|
|
550
|
+
subscriptionId: subscription.id,
|
|
551
|
+
customerId: customer.id,
|
|
552
|
+
productId: price?.product_id,
|
|
553
|
+
priceId: price?.id,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function handleAppStoreRenewed(
|
|
558
|
+
subscription: Subscription,
|
|
559
|
+
transaction: AppStoreTransactionPayload
|
|
560
|
+
): Promise<void> {
|
|
561
|
+
const newExpiry = transaction.expiresDate ? Math.floor(Number(transaction.expiresDate) / 1000) : undefined;
|
|
562
|
+
await subscription.update({
|
|
563
|
+
status: 'active',
|
|
564
|
+
current_period_end: newExpiry ?? subscription.current_period_end,
|
|
565
|
+
payment_details: {
|
|
566
|
+
...(subscription.payment_details || {}),
|
|
567
|
+
app_store: {
|
|
568
|
+
...(subscription.payment_details?.app_store || {
|
|
569
|
+
original_transaction_id: transaction.originalTransactionId,
|
|
570
|
+
product_id: transaction.productId,
|
|
571
|
+
}),
|
|
572
|
+
transaction_id: transaction.transactionId,
|
|
573
|
+
expires_at: newExpiry,
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
});
|
|
577
|
+
createEvent('Subscription', 'customer.subscription.started', subscription).catch(console.error);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function markAppStoreExpired(subscription: Subscription): Promise<void> {
|
|
581
|
+
if (['canceled', 'incomplete_expired'].includes(subscription.status as string)) return;
|
|
582
|
+
await subscription.update({ status: 'canceled', ended_at: Math.floor(Date.now() / 1000) });
|
|
583
|
+
createEvent('Subscription', 'customer.subscription.deleted', subscription).catch(console.error);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function markAppStorePastDue(subscription: Subscription): Promise<void> {
|
|
587
|
+
if (subscription.status === 'past_due') return;
|
|
588
|
+
await subscription.update({ status: 'past_due' });
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async function scheduleAppStoreCancelAtPeriodEnd(subscription: Subscription): Promise<void> {
|
|
592
|
+
if (subscription.cancel_at_period_end) return;
|
|
593
|
+
await subscription.update({ cancel_at_period_end: true });
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function unscheduleAppStoreCancel(subscription: Subscription): Promise<void> {
|
|
597
|
+
if (!subscription.cancel_at_period_end) return;
|
|
598
|
+
await subscription.update({ cancel_at_period_end: false });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function handleAppStoreRevoked(
|
|
602
|
+
subscription: Subscription,
|
|
603
|
+
notificationType: string,
|
|
604
|
+
subtype: string | undefined
|
|
605
|
+
): Promise<void> {
|
|
606
|
+
const isRefund = notificationType === 'REFUND';
|
|
607
|
+
const now = Math.floor(Date.now() / 1000);
|
|
608
|
+
await subscription.update({
|
|
609
|
+
status: 'canceled',
|
|
610
|
+
ended_at: now,
|
|
611
|
+
cancelation_details: {
|
|
612
|
+
...(subscription.cancelation_details ?? { comment: '', feedback: 'other' }),
|
|
613
|
+
reason: 'cancellation_requested',
|
|
614
|
+
},
|
|
615
|
+
metadata: {
|
|
616
|
+
...(subscription.metadata || {}),
|
|
617
|
+
app_store_last_notification_type: notificationType,
|
|
618
|
+
app_store_last_notification_subtype: subtype,
|
|
619
|
+
...(isRefund ? { refunded: true, refunded_at: now } : {}),
|
|
620
|
+
},
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// IAP refunds are mirrored as subscription state only — the canonical refund
|
|
624
|
+
// record lives at Apple. We deliberately do NOT create a local Refund ledger
|
|
625
|
+
// row; user-facing refund/receipt history is in the App Store.
|
|
626
|
+
// See docs/superpowers/specs/2026-06-04-iap-records-organization-design.md.
|
|
627
|
+
logger.info('app_store REVOKE/REFUND — status mirrored to canceled', {
|
|
628
|
+
subscriptionId: subscription.id,
|
|
629
|
+
notificationType,
|
|
630
|
+
subtype,
|
|
631
|
+
refunded: isRefund,
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
createEvent('Subscription', 'customer.subscription.deleted', subscription).catch(console.error);
|
|
635
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Minimal ambient declarations for `node-apple-receipt-verify`.
|
|
2
|
+
// The library ships no @types package; we only use config() + validate(),
|
|
3
|
+
// and accept that validate() returns `unknown` items we narrow ourselves.
|
|
4
|
+
|
|
5
|
+
declare module 'node-apple-receipt-verify' {
|
|
6
|
+
export interface AppleReceiptConfig {
|
|
7
|
+
secret?: string;
|
|
8
|
+
verbose?: boolean;
|
|
9
|
+
environment?: Array<'production' | 'sandbox'>;
|
|
10
|
+
/** ignore expired items, default false */
|
|
11
|
+
ignoreExpired?: boolean;
|
|
12
|
+
/** extended fields, etc. */
|
|
13
|
+
[k: string]: any;
|
|
14
|
+
}
|
|
15
|
+
export function config(options: AppleReceiptConfig): void;
|
|
16
|
+
export function validate(options: { receipt: string }): Promise<unknown[]>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Pure helper (no DB/SDK imports) for App Store Server Notification routing.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Decode an ASSN signedPayload's JWS body WITHOUT verifying its signature — used
|
|
5
|
+
* ONLY to read bundleId/environment so the webhook can select the matching
|
|
6
|
+
* PaymentMethod before doing the trusted verification with THAT method's client.
|
|
7
|
+
* Never trust these values for anything else.
|
|
8
|
+
*/
|
|
9
|
+
export function peekNotificationRouting(signedPayload: string): { bundleId?: string; environment?: string } | null {
|
|
10
|
+
const seg = signedPayload.split('.')[1];
|
|
11
|
+
if (!seg) return null;
|
|
12
|
+
try {
|
|
13
|
+
const payload = JSON.parse(Buffer.from(seg, 'base64url').toString('utf8'));
|
|
14
|
+
return { bundleId: payload?.data?.bundleId, environment: payload?.data?.environment };
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|