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,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,
|