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.
Files changed (76) hide show
  1. package/api/src/crons/index.ts +22 -0
  2. package/api/src/crons/retry-pending-events.ts +58 -0
  3. package/api/src/integrations/app-store/apple-root-certs.ts +26 -0
  4. package/api/src/integrations/app-store/client.ts +369 -0
  5. package/api/src/integrations/app-store/handlers/index.ts +46 -0
  6. package/api/src/integrations/app-store/handlers/subscription.ts +635 -0
  7. package/api/src/integrations/app-store/node-apple-receipt-verify.d.ts +17 -0
  8. package/api/src/integrations/app-store/notification-routing.ts +18 -0
  9. package/api/src/integrations/app-store/signed-data-verifier.ts +150 -0
  10. package/api/src/integrations/google-play/client.ts +276 -0
  11. package/api/src/integrations/google-play/handlers/index.ts +69 -0
  12. package/api/src/integrations/google-play/handlers/subscription.ts +565 -0
  13. package/api/src/integrations/google-play/handlers/voided.ts +106 -0
  14. package/api/src/integrations/google-play/setup.ts +43 -0
  15. package/api/src/integrations/google-play/verify.ts +251 -0
  16. package/api/src/integrations/iap-reconcile.ts +415 -0
  17. package/api/src/libs/audit.ts +38 -8
  18. package/api/src/libs/entitlement.ts +399 -0
  19. package/api/src/libs/env.ts +2 -0
  20. package/api/src/libs/security.ts +51 -0
  21. package/api/src/libs/subscription.ts +13 -1
  22. package/api/src/libs/util.ts +13 -0
  23. package/api/src/queues/event.ts +25 -19
  24. package/api/src/queues/webhook.ts +12 -2
  25. package/api/src/routes/entitlements.ts +105 -0
  26. package/api/src/routes/events.ts +2 -2
  27. package/api/src/routes/index.ts +12 -2
  28. package/api/src/routes/integrations/app-store.ts +267 -0
  29. package/api/src/routes/integrations/google-play.ts +324 -0
  30. package/api/src/routes/payment-methods.ts +130 -0
  31. package/api/src/store/migrations/20260526-iap-foundation.ts +105 -0
  32. package/api/src/store/models/customer.ts +14 -0
  33. package/api/src/store/models/entitlement-grant.ts +118 -0
  34. package/api/src/store/models/entitlement-product.ts +48 -0
  35. package/api/src/store/models/entitlement.ts +86 -0
  36. package/api/src/store/models/index.ts +9 -0
  37. package/api/src/store/models/invoice.ts +20 -0
  38. package/api/src/store/models/payment-method.ts +62 -1
  39. package/api/src/store/models/refund.ts +10 -0
  40. package/api/src/store/models/subscription.ts +14 -0
  41. package/api/src/store/models/types.ts +32 -0
  42. package/api/tests/integrations/app-store/client.spec.ts +335 -0
  43. package/api/tests/integrations/app-store/handlers.spec.ts +480 -0
  44. package/api/tests/integrations/app-store/notifications.spec.ts +381 -0
  45. package/api/tests/integrations/app-store/signed-data-verifier.spec.ts +72 -0
  46. package/api/tests/integrations/app-store/webhook-routing.spec.ts +27 -0
  47. package/api/tests/integrations/google-play/handlers.spec.ts +341 -0
  48. package/api/tests/integrations/google-play/verify.spec.ts +215 -0
  49. package/api/tests/integrations/iap-reconcile.spec.ts +237 -0
  50. package/api/tests/libs/entitlement.spec.ts +347 -0
  51. package/blocklet.yml +1 -1
  52. package/cloudflare/docs/2026-06-10-bundle-size-analysis.md +288 -0
  53. package/cloudflare/migrations/0004_iap_foundation.sql +72 -0
  54. package/cloudflare/migrations/0005_iap_tenant_backfill.sql +112 -0
  55. package/cloudflare/run-build.js +23 -1
  56. package/cloudflare/shims/blocklet-sdk/verify-session.ts +44 -0
  57. package/cloudflare/shims/node-fetch.ts +35 -0
  58. package/cloudflare/shims/queue.ts +28 -2
  59. package/cloudflare/shims/sequelize-d1/model.ts +19 -0
  60. package/cloudflare/shims/sequelize-d1/operators.ts +14 -1
  61. package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +87 -0
  62. package/cloudflare/worker.ts +59 -4
  63. package/cloudflare/wrangler.jsonc +7 -1
  64. package/cloudflare/wrangler.staging.json +2 -1
  65. package/package.json +10 -6
  66. package/scripts/seed-google-play.ts +79 -0
  67. package/src/components/payment-method/app-store.tsx +103 -0
  68. package/src/components/payment-method/form.tsx +7 -1
  69. package/src/components/payment-method/google-play.tsx +85 -0
  70. package/src/components/subscription/list.tsx +20 -0
  71. package/src/locales/en.tsx +63 -0
  72. package/src/locales/zh.tsx +63 -0
  73. package/src/pages/admin/billing/subscriptions/detail.tsx +80 -0
  74. package/src/pages/admin/customers/customers/detail.tsx +6 -0
  75. package/src/pages/admin/settings/payment-methods/create.tsx +12 -0
  76. package/src/pages/admin/settings/payment-methods/index.tsx +1 -1
@@ -0,0 +1,324 @@
1
+ // Google Play Real-Time Developer Notification webhook receiver.
2
+ //
3
+ // Pub/Sub Push body:
4
+ // {
5
+ // "message": { "data": "<base64 JSON>", "messageId": "...", "publishTime": "..." },
6
+ // "subscription": "projects/<project>/subscriptions/<sub>"
7
+ // }
8
+ //
9
+ // Auth: Pub/Sub puts a Google-signed JWT in `Authorization: Bearer <jwt>`.
10
+ // We verify the JWT claims here (signature verification is TODO — see verify.ts).
11
+
12
+ import { Request, Response, Router } from 'express';
13
+ import Joi from 'joi';
14
+
15
+ import handleGooglePlayEvent, { GooglePlayRtdnPayload } from '../../integrations/google-play/handlers';
16
+ import { ingestVerifiedGooglePlayPurchase } from '../../integrations/google-play/handlers/subscription';
17
+ import { decodePubSubMessage, verifyPubSubJwt } from '../../integrations/google-play/verify';
18
+ import logger from '../../libs/logger';
19
+ import { authenticate } from '../../libs/security';
20
+ import { googlePlayEndpoint } from '../../libs/util';
21
+ import { Customer, PaymentMethod } from '../../store/models';
22
+
23
+ const router = Router();
24
+ const userAuth = authenticate<Customer>({ component: false, ensureLogin: true });
25
+
26
+ const verifyBodySchema = Joi.object<{
27
+ purchaseToken: string;
28
+ subscriptionId: string;
29
+ }>({
30
+ purchaseToken: Joi.string().required(),
31
+ subscriptionId: Joi.string().required(),
32
+ });
33
+
34
+ /**
35
+ * Client-initiated verify (aistro-shape).
36
+ * Mobile client POSTs after StoreKit / BillingClient finishes the purchase.
37
+ */
38
+ router.post('/verify', userAuth, async (req: Request, res: Response) => {
39
+ try {
40
+ const did = (req as any).user?.did;
41
+ if (!did) {
42
+ res.status(401).json({ error: 'unauthenticated' });
43
+ return;
44
+ }
45
+ const input = await verifyBodySchema.validateAsync(req.body, { stripUnknown: true });
46
+
47
+ // Resolve the Google Play PaymentMethod for THIS livemode. Without the
48
+ // livemode filter a testmode request would silently fall through to the
49
+ // production method (and vice versa), and its encrypted credentials may
50
+ // not even decrypt under the current process key.
51
+ const method = await PaymentMethod.findOne({
52
+ where: { type: 'google_play', active: true, livemode: !!req.livemode },
53
+ });
54
+ if (!method) {
55
+ res.status(503).json({ error: 'google_play PaymentMethod not configured' });
56
+ return;
57
+ }
58
+ const client = method.getGooglePlayClient();
59
+
60
+ const result = await ingestVerifiedGooglePlayPurchase({
61
+ customerDid: did,
62
+ paymentMethod: method,
63
+ client,
64
+ purchaseToken: input.purchaseToken,
65
+ subscriptionId: input.subscriptionId,
66
+ });
67
+
68
+ res.json({
69
+ success: true,
70
+ subscription_id: result.subscription.id,
71
+ isFirstSubscribe: result.isFirstSubscribe,
72
+ active: result.subscription.status === 'active',
73
+ expires_at: result.subscription.current_period_end,
74
+ purchase: {
75
+ order_id: result.purchase.orderId,
76
+ expiry_time_millis: result.purchase.expiryTimeMillis,
77
+ acknowledgement_state: result.purchase.acknowledgementState,
78
+ },
79
+ });
80
+ } catch (err: any) {
81
+ // google-play-billing-validator surfaces some failures via `errorMessage`
82
+ // rather than throwing with a populated message; fall back through both.
83
+ const message = err?.message || err?.errorMessage || (typeof err === 'string' ? err : null) || 'verify failed';
84
+ logger.error('google_play verify failed', {
85
+ message,
86
+ errKeys: err ? Object.keys(err) : [],
87
+ stack: err?.stack,
88
+ });
89
+ res.status(400).json({
90
+ success: false,
91
+ error: { message, raw: err?.errorMessage ?? null },
92
+ });
93
+ }
94
+ });
95
+
96
+ // Restore-side input caps. BillingClient `queryPurchases()` typically returns
97
+ // 1-2 active subs per Play account; cap an order of magnitude higher to
98
+ // tolerate misbehaving clients without blocking legitimate uses. Each
99
+ // purchaseToken is a base64-ish blob (~150-200 chars); 2 KB is plenty.
100
+ // Play subscription IDs are bounded to 40 chars by the console — give 256.
101
+ const RESTORE_MAX_ITEMS = 50;
102
+ const PURCHASE_TOKEN_MAX_LENGTH = 2 * 1024;
103
+ const SUBSCRIPTION_ID_MAX_LENGTH = 256;
104
+ // Verify pool size. Each restore item triggers a Google Developer API
105
+ // purchases.subscriptions.get call + a DB upsert. Bound the pool so an
106
+ // authenticated request can't fan out into many concurrent Google calls.
107
+ const RESTORE_CONCURRENCY = 5;
108
+
109
+ const restoreBodySchema = Joi.object<{
110
+ purchases: Array<{ purchaseToken: string; subscriptionId: string }>;
111
+ }>({
112
+ purchases: Joi.array()
113
+ .items(
114
+ Joi.object({
115
+ purchaseToken: Joi.string().max(PURCHASE_TOKEN_MAX_LENGTH).required(),
116
+ subscriptionId: Joi.string().max(SUBSCRIPTION_ID_MAX_LENGTH).required(),
117
+ })
118
+ )
119
+ .min(1)
120
+ .max(RESTORE_MAX_ITEMS)
121
+ .required(),
122
+ });
123
+
124
+ /**
125
+ * Restore purchases for Google Play.
126
+ *
127
+ * BillingClient on Android exposes `queryPurchases()` which returns active
128
+ * purchases from the Play cache. The mobile client iterates that list and
129
+ * posts each {purchaseToken, subscriptionId} pair here. We re-verify and
130
+ * either return the existing local Subscription or create one. Partial
131
+ * success is reported per item.
132
+ */
133
+ router.post('/restore', userAuth, async (req: Request, res: Response) => {
134
+ try {
135
+ const did = (req as any).user?.did;
136
+ if (!did) {
137
+ res.status(401).json({ error: 'unauthenticated' });
138
+ return;
139
+ }
140
+ const input = await restoreBodySchema.validateAsync(req.body, { stripUnknown: true });
141
+
142
+ const method = await PaymentMethod.findOne({
143
+ where: { type: 'google_play', active: true, livemode: !!req.livemode },
144
+ });
145
+ if (!method) {
146
+ res.status(503).json({ error: 'google_play PaymentMethod not configured' });
147
+ return;
148
+ }
149
+ const client = method.getGooglePlayClient();
150
+
151
+ // Dedupe by purchaseToken — a single token is unique to one Google
152
+ // Play purchase, so duplicates in the request would otherwise double-
153
+ // call Google's verifier and re-upsert the same Subscription row.
154
+ const seen = new Set<string>();
155
+ const purchases = input.purchases.filter((p) => {
156
+ if (seen.has(p.purchaseToken)) return false;
157
+ seen.add(p.purchaseToken);
158
+ return true;
159
+ });
160
+
161
+ // Bounded concurrency: process in fixed-size batches. Each item hits
162
+ // Google's Developer API + at least one DB write; Promise.all over
163
+ // an arbitrary list lets a single authenticated request fan out into
164
+ // many concurrent Google calls.
165
+ type ItemResult =
166
+ | {
167
+ ok: true;
168
+ subscription_id: string;
169
+ isFirstSubscribe: boolean;
170
+ product_id: string;
171
+ }
172
+ | { ok: false; error: string; product_id: string };
173
+ const results: ItemResult[] = [];
174
+ for (let i = 0; i < purchases.length; i += RESTORE_CONCURRENCY) {
175
+ const batch = purchases.slice(i, i + RESTORE_CONCURRENCY);
176
+ // eslint-disable-next-line no-await-in-loop -- intentional: batches must complete sequentially to bound concurrency
177
+ const batchResults = await Promise.all(
178
+ batch.map(async (p): Promise<ItemResult> => {
179
+ try {
180
+ const r = await ingestVerifiedGooglePlayPurchase({
181
+ customerDid: did,
182
+ paymentMethod: method,
183
+ client,
184
+ purchaseToken: p.purchaseToken,
185
+ subscriptionId: p.subscriptionId,
186
+ });
187
+ return {
188
+ ok: true,
189
+ subscription_id: r.subscription.id,
190
+ isFirstSubscribe: r.isFirstSubscribe,
191
+ product_id: p.subscriptionId,
192
+ };
193
+ } catch (err: any) {
194
+ return {
195
+ ok: false,
196
+ error: err?.message ?? 'restore failed',
197
+ product_id: p.subscriptionId,
198
+ };
199
+ }
200
+ })
201
+ );
202
+ results.push(...batchResults);
203
+ }
204
+
205
+ res.json({
206
+ restored: results.filter((r) => r.ok),
207
+ errors: results.filter((r) => !r.ok),
208
+ });
209
+ } catch (err: any) {
210
+ logger.error('google_play restore failed', { error: err?.message, stack: err?.stack });
211
+ res.status(400).json({ error: err?.message ?? 'restore failed' });
212
+ }
213
+ });
214
+
215
+ // In-process dedup of recently-seen Pub/Sub messageIds. Pub/Sub guarantees the
216
+ // same messageId on retries, so if we've already started handling this exact
217
+ // message we can skip duplicate delivery (Google retries even on 2xx if its
218
+ // timer expires before our response). Map<messageId, expiryEpochMs>; we cap
219
+ // the map to avoid unbounded growth.
220
+ const seenMessageIds = new Map<string, number>();
221
+ const MESSAGE_DEDUP_TTL_MS = 10 * 60 * 1000; // 10 min — Pub/Sub retries within
222
+ // ack deadline (default 10s) but
223
+ // can also redeliver on cron, so
224
+ // keep a comfortable window.
225
+ const MESSAGE_DEDUP_MAX_SIZE = 1000;
226
+
227
+ /** True if this messageId was already processed SUCCESSFULLY within the TTL. */
228
+ function wasHandled(messageId: string): boolean {
229
+ const exp = seenMessageIds.get(messageId);
230
+ return !!exp && exp > Date.now();
231
+ }
232
+
233
+ /**
234
+ * Mark a messageId as successfully handled. Called ONLY after processing
235
+ * succeeds — so a failed/transient attempt is NOT deduped away and Pub/Sub's
236
+ * retry is allowed to run (PR #1381 review P1). NOTE: in-memory, so it does not
237
+ * survive Worker restarts — durable idempotency is a follow-up.
238
+ */
239
+ function markHandled(messageId: string): void {
240
+ const now = Date.now();
241
+ if (seenMessageIds.size > MESSAGE_DEDUP_MAX_SIZE) {
242
+ for (const [id, exp] of seenMessageIds) {
243
+ if (exp < now) seenMessageIds.delete(id);
244
+ }
245
+ }
246
+ seenMessageIds.set(messageId, now + MESSAGE_DEDUP_TTL_MS);
247
+ }
248
+
249
+ router.post('/webhook', async (req: Request, res: Response) => {
250
+ const expectedEmail = process.env.GOOGLE_PUBSUB_PUSH_SERVICE_ACCOUNT;
251
+ // Fail CLOSED: in production the push service account MUST be configured. A
252
+ // sandbox/test bypass has to be explicit (PR #1381 review P1).
253
+ const allowUnverifiedSender =
254
+ process.env.GOOGLE_PUBSUB_ALLOW_UNVERIFIED_SENDER === 'true' || process.env.NODE_ENV === 'test';
255
+
256
+ // --- Phase 1: authenticate + select. Failures here are rejections / not-for-us,
257
+ // NOT processing failures. ---
258
+ let payload: GooglePlayRtdnPayload;
259
+ let client: ReturnType<PaymentMethod['getGooglePlayClient']>;
260
+ let messageId: string | undefined;
261
+ try {
262
+ if (!expectedEmail && !allowUnverifiedSender) {
263
+ logger.error(
264
+ 'google_play webhook refusing: GOOGLE_PUBSUB_PUSH_SERVICE_ACCOUNT unset ' +
265
+ '(set GOOGLE_PUBSUB_ALLOW_UNVERIFIED_SENDER=true only for sandbox)'
266
+ );
267
+ res.status(403).json({ error: 'sender verification not configured' });
268
+ return;
269
+ }
270
+
271
+ const authHeader = req.get('authorization') || req.get('Authorization');
272
+ if (authHeader) {
273
+ const token = authHeader.replace(/^Bearer\s+/i, '');
274
+ await verifyPubSubJwt(token, { expectedAudience: googlePlayEndpoint(), expectedEmail });
275
+ } else if (!allowUnverifiedSender) {
276
+ logger.warn('google_play webhook missing Authorization header');
277
+ res.status(401).json({ error: 'missing authorization' });
278
+ return;
279
+ }
280
+
281
+ messageId = req.body?.message?.messageId;
282
+ // Skip only messages we already handled SUCCESSFULLY (mark happens post-success).
283
+ if (messageId && wasHandled(messageId)) {
284
+ logger.info('google_play webhook: duplicate Pub/Sub messageId, skipping', { messageId });
285
+ res.json({ deduped: true });
286
+ return;
287
+ }
288
+
289
+ payload = decodePubSubMessage<GooglePlayRtdnPayload>(req.body);
290
+
291
+ const methods = await PaymentMethod.findAll({ where: { type: 'google_play' } });
292
+ const method = methods.find((m) => {
293
+ const settings = PaymentMethod.decryptSettings(m.settings);
294
+ return settings.google_play?.package_name === payload.packageName;
295
+ });
296
+ if (!method) {
297
+ logger.warn('google_play webhook: no matching PaymentMethod for packageName', {
298
+ packageName: payload.packageName,
299
+ });
300
+ // Not for us → ack so Pub/Sub doesn't retry a misconfigured topic forever.
301
+ res.json({ skipped: true });
302
+ return;
303
+ }
304
+ client = method.getGooglePlayClient();
305
+ } catch (err: any) {
306
+ // Auth / decode / selection failure → forged or malformed; reject.
307
+ logger.warn('google_play webhook: auth/decode failed', { error: err?.message });
308
+ res.status(401).json({ error: 'unauthorized' });
309
+ return;
310
+ }
311
+
312
+ // --- Phase 2: process the verified event. Failure here is transient → 5xx so
313
+ // Pub/Sub retries; mark the messageId handled ONLY after success. ---
314
+ try {
315
+ await handleGooglePlayEvent(payload, client);
316
+ if (messageId) markHandled(messageId);
317
+ res.json({ received: true });
318
+ } catch (err: any) {
319
+ logger.error('google_play webhook processing failed — will retry', { error: err?.message, stack: err?.stack });
320
+ res.status(500).json({ error: err?.message ?? 'processing failed' });
321
+ }
322
+ });
323
+
324
+ export default router;
@@ -183,6 +183,124 @@ router.post('/', auth, async (req, res) => {
183
183
  return res.json({ ...method.toJSON(), payment_currencies: [currency.toJSON()] });
184
184
  }
185
185
 
186
+ if (raw.type === 'google_play') {
187
+ if (!raw.settings.google_play?.package_name) {
188
+ return res.status(400).json({ error: 'google_play package_name is required' });
189
+ }
190
+ if (!raw.settings.google_play?.service_account_json) {
191
+ return res.status(400).json({ error: 'google_play service_account_json is required' });
192
+ }
193
+ try {
194
+ const parsed = JSON.parse(raw.settings.google_play.service_account_json);
195
+ if (!parsed.client_email || !parsed.private_key) {
196
+ return res.status(400).json({ error: 'service_account_json missing client_email or private_key' });
197
+ }
198
+ } catch {
199
+ return res.status(400).json({ error: 'service_account_json is not valid JSON' });
200
+ }
201
+
202
+ const exist = await PaymentMethod.findOne({
203
+ where: { type: 'google_play', livemode: raw.livemode },
204
+ });
205
+ if (exist) {
206
+ return res.status(400).json({ error: 'google_play payment method already exists for this livemode' });
207
+ }
208
+
209
+ raw.settings = pick(PaymentMethod.encryptSettings(raw.settings), ['google_play']) as PaymentMethodSettings;
210
+ raw.logo = raw.logo || getUrl('/methods/google-play.png');
211
+ raw.features = { recurring: true, refund: true, dispute: false };
212
+ raw.confirmation = { type: 'callback' };
213
+
214
+ const method = await PaymentMethod.create(raw as TPaymentMethod);
215
+
216
+ // Create a default USD currency for the PaymentMethod (mirrors stripe path).
217
+ const currency = await PaymentCurrency.create({
218
+ livemode: method.livemode,
219
+ active: method.active,
220
+ locked: true,
221
+ is_base_currency: false,
222
+ payment_method_id: method.id,
223
+ type: 'standard',
224
+ name: 'Dollar',
225
+ description: 'US Dollar (Google Play)',
226
+ logo: getUrl('/currencies/dollar.png'),
227
+ symbol: 'USD',
228
+ decimal: 2,
229
+ maximum_precision: 2,
230
+ minimum_payment_amount: '1',
231
+ maximum_payment_amount: '100000000000',
232
+ contract: '',
233
+ metadata: {},
234
+ });
235
+ await method.update({ default_currency_id: currency.id });
236
+
237
+ return res.json({ ...method.toJSON(), payment_currencies: [currency.toJSON()] });
238
+ }
239
+
240
+ if (raw.type === 'app_store') {
241
+ if (!raw.settings.app_store?.bundle_id) {
242
+ return res.status(400).json({ error: 'app_store bundle_id is required' });
243
+ }
244
+ const env = raw.settings.app_store?.environment;
245
+ if (env !== 'production' && env !== 'sandbox') {
246
+ return res.status(400).json({ error: 'app_store environment must be production or sandbox' });
247
+ }
248
+ // Server API credentials are optional — StoreKit 2 JWS verify doesn't need them.
249
+ // But if any of the three is set, all three must be set together.
250
+ const hasAnyServerCred = !!(
251
+ raw.settings.app_store?.issuer_id ||
252
+ raw.settings.app_store?.key_id ||
253
+ raw.settings.app_store?.private_key_pem
254
+ );
255
+ if (hasAnyServerCred) {
256
+ if (
257
+ !raw.settings.app_store?.issuer_id ||
258
+ !raw.settings.app_store?.key_id ||
259
+ !raw.settings.app_store?.private_key_pem
260
+ ) {
261
+ return res.status(400).json({
262
+ error: 'app_store Server API credentials must include all of issuer_id, key_id, private_key_pem',
263
+ });
264
+ }
265
+ }
266
+
267
+ const exist = await PaymentMethod.findOne({
268
+ where: { type: 'app_store', livemode: raw.livemode },
269
+ });
270
+ if (exist) {
271
+ return res.status(400).json({ error: 'app_store payment method already exists for this livemode' });
272
+ }
273
+
274
+ raw.settings = pick(PaymentMethod.encryptSettings(raw.settings), ['app_store']) as PaymentMethodSettings;
275
+ raw.logo = raw.logo || getUrl('/methods/app-store.png');
276
+ raw.features = { recurring: true, refund: false, dispute: false };
277
+ raw.confirmation = { type: 'callback' };
278
+
279
+ const method = await PaymentMethod.create(raw as TPaymentMethod);
280
+
281
+ const currency = await PaymentCurrency.create({
282
+ livemode: method.livemode,
283
+ active: method.active,
284
+ locked: true,
285
+ is_base_currency: false,
286
+ payment_method_id: method.id,
287
+ type: 'standard',
288
+ name: 'Dollar',
289
+ description: 'US Dollar (App Store)',
290
+ logo: getUrl('/currencies/dollar.png'),
291
+ symbol: 'USD',
292
+ decimal: 2,
293
+ maximum_precision: 2,
294
+ minimum_payment_amount: '1',
295
+ maximum_payment_amount: '100000000000',
296
+ contract: '',
297
+ metadata: {},
298
+ });
299
+ await method.update({ default_currency_id: currency.id });
300
+
301
+ return res.json({ ...method.toJSON(), payment_currencies: [currency.toJSON()] });
302
+ }
303
+
186
304
  // FIXME: support bitcoin payment methods
187
305
 
188
306
  return res.status(400).json({ error: 'payment method type is not supported' });
@@ -259,6 +377,18 @@ router.get('/types', auth, (_, res) => {
259
377
  description: 'Pay with base compatible chains',
260
378
  logo: getUrl('/methods/base.png'),
261
379
  },
380
+ {
381
+ type: 'google_play',
382
+ name: 'Google Play',
383
+ description: 'Subscriptions purchased via Google Play in-app billing',
384
+ logo: getUrl('/methods/google-play.png'),
385
+ },
386
+ {
387
+ type: 'app_store',
388
+ name: 'App Store',
389
+ description: 'Subscriptions purchased via Apple App Store StoreKit',
390
+ logo: getUrl('/methods/app-store.png'),
391
+ },
262
392
  ]);
263
393
  });
264
394
 
@@ -0,0 +1,105 @@
1
+ import { DataTypes } from 'sequelize';
2
+ import { createIndexIfNotExists, safeApplyColumnChanges, type Migration } from '../migrate';
3
+ import models from '../models';
4
+
5
+ export const up: Migration = async ({ context }) => {
6
+ // 1. Customer: per-channel UUID for IAP appAccountToken / obfuscatedAccountId mapping (D-004)
7
+ await safeApplyColumnChanges(context, {
8
+ customers: [
9
+ { name: 'app_store_uuid', field: { type: DataTypes.STRING(36), allowNull: true } },
10
+ { name: 'google_play_uuid', field: { type: DataTypes.STRING(36), allowNull: true } },
11
+ ],
12
+ });
13
+ // SQLite/D1 allows multiple NULLs in UNIQUE columns, so a plain UNIQUE index
14
+ // is functionally equivalent to a partial one (WHERE col IS NOT NULL).
15
+ await createIndexIfNotExists(context, 'customers', ['app_store_uuid'], 'idx_customers_app_store_uuid', {
16
+ unique: true,
17
+ });
18
+ await createIndexIfNotExists(context, 'customers', ['google_play_uuid'], 'idx_customers_google_play_uuid', {
19
+ unique: true,
20
+ });
21
+
22
+ // 2. Subscription: channel + environment (D-005)
23
+ await safeApplyColumnChanges(context, {
24
+ subscriptions: [
25
+ { name: 'channel', field: { type: DataTypes.STRING(20), allowNull: true } },
26
+ {
27
+ name: 'environment',
28
+ field: { type: DataTypes.STRING(20), allowNull: true, defaultValue: 'production' },
29
+ },
30
+ ],
31
+ });
32
+
33
+ // 3. Invoice: three-segment amounts for cross-channel accounting (D-001 A)
34
+ await safeApplyColumnChanges(context, {
35
+ invoices: [
36
+ { name: 'gross_amount', field: { type: DataTypes.STRING(32), allowNull: true } },
37
+ { name: 'platform_fee', field: { type: DataTypes.STRING(32), defaultValue: '0' } },
38
+ { name: 'net_amount', field: { type: DataTypes.STRING(32), allowNull: true } },
39
+ ],
40
+ });
41
+ // Backfill: existing Stripe / on-chain rows have no platform fee, so gross = net = total
42
+ await context.sequelize.query(
43
+ 'UPDATE invoices SET gross_amount = total, net_amount = total WHERE gross_amount IS NULL'
44
+ );
45
+
46
+ // 4. Refund: source (merchant_initiated | platform_initiated)
47
+ await safeApplyColumnChanges(context, {
48
+ refunds: [
49
+ {
50
+ name: 'source',
51
+ field: { type: DataTypes.STRING(30), allowNull: true, defaultValue: 'merchant_initiated' },
52
+ },
53
+ ],
54
+ });
55
+
56
+ // 5. Entitlement tables (D-003 B)
57
+ await context.createTable('entitlements', models.Entitlement.GENESIS_ATTRIBUTES);
58
+ await createIndexIfNotExists(context, 'entitlements', ['key'], 'idx_entitlements_key', { unique: true });
59
+
60
+ await context.createTable('entitlement_products', models.EntitlementProduct.GENESIS_ATTRIBUTES);
61
+
62
+ await context.createTable('entitlement_grants', models.EntitlementGrant.GENESIS_ATTRIBUTES);
63
+ await createIndexIfNotExists(
64
+ context,
65
+ 'entitlement_grants',
66
+ ['customer_id', 'entitlement_id', 'status'],
67
+ 'idx_entitlement_grants_lookup'
68
+ );
69
+ await createIndexIfNotExists(
70
+ context,
71
+ 'entitlement_grants',
72
+ ['source_subscription_id'],
73
+ 'idx_entitlement_grants_source_sub'
74
+ );
75
+
76
+ // Note: PaymentMethod.type ENUM extension to include 'app_store' / 'google_play'
77
+ // is intentionally NOT changed at DB level — on D1/SQLite the column is already TEXT
78
+ // with no CHECK constraint, so any value is accepted. The model-level type union
79
+ // is updated in models/payment-method.ts when A1/A2 lands.
80
+ };
81
+
82
+ export const down: Migration = async ({ context }) => {
83
+ await context.removeIndex('entitlement_grants', 'idx_entitlement_grants_source_sub');
84
+ await context.removeIndex('entitlement_grants', 'idx_entitlement_grants_lookup');
85
+ await context.dropTable('entitlement_grants');
86
+
87
+ await context.dropTable('entitlement_products');
88
+
89
+ await context.removeIndex('entitlements', 'idx_entitlements_key');
90
+ await context.dropTable('entitlements');
91
+
92
+ await context.removeColumn('refunds', 'source');
93
+
94
+ await context.removeColumn('invoices', 'net_amount');
95
+ await context.removeColumn('invoices', 'platform_fee');
96
+ await context.removeColumn('invoices', 'gross_amount');
97
+
98
+ await context.removeColumn('subscriptions', 'environment');
99
+ await context.removeColumn('subscriptions', 'channel');
100
+
101
+ await context.removeIndex('customers', 'idx_customers_google_play_uuid');
102
+ await context.removeIndex('customers', 'idx_customers_app_store_uuid');
103
+ await context.removeColumn('customers', 'google_play_uuid');
104
+ await context.removeColumn('customers', 'app_store_uuid');
105
+ };
@@ -57,6 +57,10 @@ export class Customer extends Model<InferAttributes<Customer>, InferCreationAttr
57
57
  };
58
58
  declare next_invoice_sequence?: number;
59
59
 
60
+ // Per-channel UUID for IAP appAccountToken / obfuscatedAccountId mapping (D-004)
61
+ declare app_store_uuid?: string;
62
+ declare google_play_uuid?: string;
63
+
60
64
  // TODO: following fields not supported
61
65
  // declare preferred_locales?: string[];
62
66
  // declare tax_exempt?: LiteralUnion<'exempt' | 'none' | 'reverse', string>;
@@ -143,6 +147,16 @@ export class Customer extends Model<InferAttributes<Customer>, InferCreationAttr
143
147
  type: DataTypes.NUMBER,
144
148
  defaultValue: 1,
145
149
  },
150
+ app_store_uuid: {
151
+ type: DataTypes.STRING(36),
152
+ allowNull: true,
153
+ unique: true,
154
+ },
155
+ google_play_uuid: {
156
+ type: DataTypes.STRING(36),
157
+ allowNull: true,
158
+ unique: true,
159
+ },
146
160
  created_at: {
147
161
  type: DataTypes.DATE,
148
162
  defaultValue: DataTypes.NOW,