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,237 @@
|
|
|
1
|
+
/* eslint-disable require-await, import/first */
|
|
2
|
+
// Unit tests for iap-reconcile drift detection.
|
|
3
|
+
//
|
|
4
|
+
// The DB query path (Subscription.findAll + per-row update) is not exercised
|
|
5
|
+
// here — it's a thin loop over the drift functions. Drift detection is the
|
|
6
|
+
// only place where state-machine logic lives, so that's what we test.
|
|
7
|
+
|
|
8
|
+
const mockCreateEvent: jest.Mock<Promise<undefined>, any[]> = jest.fn(async () => undefined);
|
|
9
|
+
|
|
10
|
+
jest.mock('../../src/store/models', () => ({
|
|
11
|
+
Subscription: {},
|
|
12
|
+
PaymentMethod: {},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
jest.mock('../../src/libs/audit', () => ({
|
|
16
|
+
createEvent: (...args: any[]) => mockCreateEvent(...args),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
applyAppStoreTransactionDrift,
|
|
21
|
+
applyGooglePlayPurchaseDrift,
|
|
22
|
+
pickIapMethodForSub,
|
|
23
|
+
} from '../../src/integrations/iap-reconcile';
|
|
24
|
+
import type { AppStoreTransactionPayload } from '../../src/integrations/app-store/client';
|
|
25
|
+
import type { GooglePlaySubscriptionPurchase } from '../../src/integrations/google-play/client';
|
|
26
|
+
|
|
27
|
+
type SubFake = {
|
|
28
|
+
id: string;
|
|
29
|
+
status: string;
|
|
30
|
+
current_period_end: number;
|
|
31
|
+
cancel_at_period_end?: boolean;
|
|
32
|
+
payment_details?: any;
|
|
33
|
+
metadata?: any;
|
|
34
|
+
update: jest.Mock;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const makeSub = (overrides: Partial<SubFake> = {}): SubFake => {
|
|
38
|
+
const sub: SubFake = {
|
|
39
|
+
id: 'sub_1',
|
|
40
|
+
status: 'active',
|
|
41
|
+
current_period_end: Math.floor(Date.now() / 1000) + 30 * 86400, // 30 days out
|
|
42
|
+
cancel_at_period_end: false,
|
|
43
|
+
payment_details: { app_store: { original_transaction_id: 'orig_1' } },
|
|
44
|
+
metadata: {},
|
|
45
|
+
update: jest.fn(async (patch: any) => Object.assign(sub, patch)) as any,
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
return sub;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const makeAppStoreTxn = (overrides: Partial<AppStoreTransactionPayload> = {}): AppStoreTransactionPayload =>
|
|
52
|
+
({
|
|
53
|
+
transactionId: 'txn_1',
|
|
54
|
+
originalTransactionId: 'orig_1',
|
|
55
|
+
productId: 'pro_monthly',
|
|
56
|
+
bundleId: 'com.example.app',
|
|
57
|
+
environment: 'Sandbox',
|
|
58
|
+
expiresDate: Date.now() + 30 * 86400 * 1000,
|
|
59
|
+
purchaseDate: Date.now() - 60_000,
|
|
60
|
+
...overrides,
|
|
61
|
+
}) as AppStoreTransactionPayload;
|
|
62
|
+
|
|
63
|
+
const makeGooglePurchase = (overrides: Partial<GooglePlaySubscriptionPurchase> = {}): GooglePlaySubscriptionPurchase =>
|
|
64
|
+
({
|
|
65
|
+
expiryTimeMillis: String(Date.now() + 30 * 86400 * 1000),
|
|
66
|
+
autoRenewing: true,
|
|
67
|
+
...overrides,
|
|
68
|
+
}) as GooglePlaySubscriptionPurchase;
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
mockCreateEvent.mockClear();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('pickIapMethodForSub', () => {
|
|
75
|
+
const m = (id: string) => ({ id }) as any;
|
|
76
|
+
|
|
77
|
+
it('selects the method bound via default_payment_method_id', () => {
|
|
78
|
+
const methods = [m('pm_a'), m('pm_b')];
|
|
79
|
+
const sub = { id: 's1', default_payment_method_id: 'pm_b' } as any;
|
|
80
|
+
expect(pickIapMethodForSub(methods, sub)?.id).toBe('pm_b');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('returns undefined (no arbitrary fallback) when bound method is absent and several exist', () => {
|
|
84
|
+
const methods = [m('pm_a'), m('pm_b')];
|
|
85
|
+
const sub = { id: 's1', default_payment_method_id: 'pm_missing' } as any;
|
|
86
|
+
expect(pickIapMethodForSub(methods, sub)).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('defaults to the sole method when there is exactly one', () => {
|
|
90
|
+
const methods = [m('pm_only')];
|
|
91
|
+
const sub = { id: 's1', default_payment_method_id: undefined } as any;
|
|
92
|
+
expect(pickIapMethodForSub(methods, sub)?.id).toBe('pm_only');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('applyAppStoreTransactionDrift', () => {
|
|
97
|
+
it('no-ops when Apple state matches local', async () => {
|
|
98
|
+
const expires = Math.floor(Date.now() / 1000) + 30 * 86400;
|
|
99
|
+
const sub = makeSub({ current_period_end: expires });
|
|
100
|
+
const txn = makeAppStoreTxn({ expiresDate: expires * 1000 });
|
|
101
|
+
|
|
102
|
+
const changed = await applyAppStoreTransactionDrift(sub as any, txn);
|
|
103
|
+
expect(changed).toBe(false);
|
|
104
|
+
expect(sub.update).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('updates current_period_end when Apple says we renewed (forward jump)', async () => {
|
|
108
|
+
const oldExpires = Math.floor(Date.now() / 1000) + 86400; // 1 day out
|
|
109
|
+
const newExpiresSec = oldExpires + 30 * 86400; // renewed for 30 more days
|
|
110
|
+
const sub = makeSub({ current_period_end: oldExpires });
|
|
111
|
+
const txn = makeAppStoreTxn({ expiresDate: newExpiresSec * 1000 });
|
|
112
|
+
|
|
113
|
+
const changed = await applyAppStoreTransactionDrift(sub as any, txn);
|
|
114
|
+
expect(changed).toBe(true);
|
|
115
|
+
expect(sub.update).toHaveBeenCalledTimes(1);
|
|
116
|
+
const patch = sub.update.mock.calls[0]![0];
|
|
117
|
+
expect(patch.current_period_end).toBe(newExpiresSec);
|
|
118
|
+
expect(patch.status).toBe('active');
|
|
119
|
+
expect(mockCreateEvent).toHaveBeenCalledWith('Subscription', 'customer.subscription.updated', sub);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('marks canceled when transaction has revocationDate (refund/revoke)', async () => {
|
|
123
|
+
const sub = makeSub({ status: 'active' });
|
|
124
|
+
const revocationDate = Date.now() - 60_000;
|
|
125
|
+
const txn = makeAppStoreTxn({ revocationDate });
|
|
126
|
+
|
|
127
|
+
const changed = await applyAppStoreTransactionDrift(sub as any, txn);
|
|
128
|
+
expect(changed).toBe(true);
|
|
129
|
+
const patch = sub.update.mock.calls[0]![0];
|
|
130
|
+
expect(patch.status).toBe('canceled');
|
|
131
|
+
expect(patch.canceled_at).toBe(Math.floor(revocationDate / 1000));
|
|
132
|
+
expect(patch.cancelation_details.reason).toBe('app_store_revoked');
|
|
133
|
+
expect(mockCreateEvent).toHaveBeenCalledWith('Subscription', 'customer.subscription.deleted', sub);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('idempotent: does not re-cancel already-canceled subscription', async () => {
|
|
137
|
+
const sub = makeSub({ status: 'canceled' });
|
|
138
|
+
const txn = makeAppStoreTxn({ revocationDate: Date.now() - 60_000 });
|
|
139
|
+
|
|
140
|
+
const changed = await applyAppStoreTransactionDrift(sub as any, txn);
|
|
141
|
+
expect(changed).toBe(false);
|
|
142
|
+
expect(sub.update).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('marks canceled when Apple says expired but local still active (webhook miss on EXPIRED)', async () => {
|
|
146
|
+
const expiredSec = Math.floor(Date.now() / 1000) - 3600; // 1h in the past
|
|
147
|
+
const sub = makeSub({ status: 'active', current_period_end: expiredSec });
|
|
148
|
+
const txn = makeAppStoreTxn({ expiresDate: expiredSec * 1000 });
|
|
149
|
+
|
|
150
|
+
const changed = await applyAppStoreTransactionDrift(sub as any, txn);
|
|
151
|
+
expect(changed).toBe(true);
|
|
152
|
+
const patch = sub.update.mock.calls[0]![0];
|
|
153
|
+
expect(patch.status).toBe('canceled');
|
|
154
|
+
expect(patch.cancelation_details.reason).toBe('app_store_expired');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('applyGooglePlayPurchaseDrift', () => {
|
|
159
|
+
it('no-ops when Google state matches local', async () => {
|
|
160
|
+
const expires = Math.floor(Date.now() / 1000) + 30 * 86400;
|
|
161
|
+
const sub = makeSub({
|
|
162
|
+
current_period_end: expires,
|
|
163
|
+
payment_details: { google_play: { purchase_token: 'tok_1' } },
|
|
164
|
+
});
|
|
165
|
+
const purchase = makeGooglePurchase({ expiryTimeMillis: String(expires * 1000) });
|
|
166
|
+
|
|
167
|
+
const changed = await applyGooglePlayPurchaseDrift(sub as any, purchase);
|
|
168
|
+
expect(changed).toBe(false);
|
|
169
|
+
expect(sub.update).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('updates current_period_end when Google says we renewed', async () => {
|
|
173
|
+
const oldExpires = Math.floor(Date.now() / 1000) + 86400;
|
|
174
|
+
const newExpiresSec = oldExpires + 30 * 86400;
|
|
175
|
+
const sub = makeSub({
|
|
176
|
+
current_period_end: oldExpires,
|
|
177
|
+
payment_details: { google_play: { purchase_token: 'tok_1' } },
|
|
178
|
+
});
|
|
179
|
+
const purchase = makeGooglePurchase({ expiryTimeMillis: String(newExpiresSec * 1000) });
|
|
180
|
+
|
|
181
|
+
const changed = await applyGooglePlayPurchaseDrift(sub as any, purchase);
|
|
182
|
+
expect(changed).toBe(true);
|
|
183
|
+
const patch = sub.update.mock.calls[0]![0];
|
|
184
|
+
expect(patch.current_period_end).toBe(newExpiresSec);
|
|
185
|
+
expect(mockCreateEvent).toHaveBeenCalledWith('Subscription', 'customer.subscription.updated', sub);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('sets cancel_at_period_end when user disabled auto-renew (cancelReason set, autoRenewing=false)', async () => {
|
|
189
|
+
const sub = makeSub({
|
|
190
|
+
cancel_at_period_end: false,
|
|
191
|
+
payment_details: { google_play: { purchase_token: 'tok_1' } },
|
|
192
|
+
});
|
|
193
|
+
const purchase = makeGooglePurchase({
|
|
194
|
+
expiryTimeMillis: String(sub.current_period_end * 1000), // not a renewal drift
|
|
195
|
+
cancelReason: 0,
|
|
196
|
+
autoRenewing: false,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const changed = await applyGooglePlayPurchaseDrift(sub as any, purchase);
|
|
200
|
+
expect(changed).toBe(true);
|
|
201
|
+
const patch = sub.update.mock.calls[0]![0];
|
|
202
|
+
expect(patch.cancel_at_period_end).toBe(true);
|
|
203
|
+
expect(patch.cancelation_details.reason).toBe('google_play_auto_renew_off');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('idempotent: does not re-flag cancel_at_period_end when already set', async () => {
|
|
207
|
+
const sub = makeSub({
|
|
208
|
+
cancel_at_period_end: true,
|
|
209
|
+
payment_details: { google_play: { purchase_token: 'tok_1' } },
|
|
210
|
+
});
|
|
211
|
+
const purchase = makeGooglePurchase({
|
|
212
|
+
expiryTimeMillis: String(sub.current_period_end * 1000),
|
|
213
|
+
cancelReason: 0,
|
|
214
|
+
autoRenewing: false,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const changed = await applyGooglePlayPurchaseDrift(sub as any, purchase);
|
|
218
|
+
expect(changed).toBe(false);
|
|
219
|
+
expect(sub.update).not.toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('marks canceled when Google says expired but local still active', async () => {
|
|
223
|
+
const expiredSec = Math.floor(Date.now() / 1000) - 3600;
|
|
224
|
+
const sub = makeSub({
|
|
225
|
+
status: 'active',
|
|
226
|
+
current_period_end: expiredSec,
|
|
227
|
+
payment_details: { google_play: { purchase_token: 'tok_1' } },
|
|
228
|
+
});
|
|
229
|
+
const purchase = makeGooglePurchase({ expiryTimeMillis: String(expiredSec * 1000) });
|
|
230
|
+
|
|
231
|
+
const changed = await applyGooglePlayPurchaseDrift(sub as any, purchase);
|
|
232
|
+
expect(changed).toBe(true);
|
|
233
|
+
const patch = sub.update.mock.calls[0]![0];
|
|
234
|
+
expect(patch.status).toBe('canceled');
|
|
235
|
+
expect(patch.cancelation_details.reason).toBe('google_play_expired');
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/* eslint-disable require-await, import/first */
|
|
2
|
+
// Unit tests for libs/entitlement.ts — cross-channel entitlement check.
|
|
3
|
+
// Models are mocked; DB is not touched.
|
|
4
|
+
|
|
5
|
+
import { checkEntitlement, listEntitlements } from '../../src/libs/entitlement';
|
|
6
|
+
|
|
7
|
+
const mockCustomerFindOne: jest.Mock<any, any[]> = jest.fn();
|
|
8
|
+
const mockSubscriptionFindAll: jest.Mock<any, any[]> = jest.fn();
|
|
9
|
+
const mockCreditGrantFindAll: jest.Mock<any, any[]> = jest.fn();
|
|
10
|
+
const mockPaymentMethodFindByPk: jest.Mock<any, any[]> = jest.fn();
|
|
11
|
+
const mockPriceFindAll: jest.Mock<any, any[]> = jest.fn();
|
|
12
|
+
|
|
13
|
+
jest.mock('../../src/store/models', () => ({
|
|
14
|
+
Customer: { findOne: (...args: any[]) => mockCustomerFindOne(...args) },
|
|
15
|
+
Subscription: { findAll: (...args: any[]) => mockSubscriptionFindAll(...args) },
|
|
16
|
+
SubscriptionItem: {},
|
|
17
|
+
Price: { findAll: (...args: any[]) => mockPriceFindAll(...args) },
|
|
18
|
+
Product: {},
|
|
19
|
+
CreditGrant: { findAll: (...args: any[]) => mockCreditGrantFindAll(...args) },
|
|
20
|
+
PaymentMethod: { findByPk: (...args: any[]) => mockPaymentMethodFindByPk(...args) },
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const makeSub = (overrides: Record<string, any> = {}) => ({
|
|
24
|
+
id: 'sub_1',
|
|
25
|
+
status: 'active',
|
|
26
|
+
channel: 'app_store',
|
|
27
|
+
current_period_end: Math.floor(Date.now() / 1000) + 30 * 86400,
|
|
28
|
+
default_payment_method_id: 'pm_1',
|
|
29
|
+
items: [{ price: { product_id: 'pro_monthly' } }],
|
|
30
|
+
...overrides,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const makeGrant = (overrides: Record<string, any> = {}) => ({
|
|
34
|
+
id: 'cg_1',
|
|
35
|
+
customer_id: 'cus_1',
|
|
36
|
+
status: 'granted',
|
|
37
|
+
remaining_amount: '100',
|
|
38
|
+
expires_at: Math.floor(Date.now() / 1000) + 30 * 86400,
|
|
39
|
+
metadata: { product_id: 'one_off_product', payment_method_id: 'pm_stripe' },
|
|
40
|
+
...overrides,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
mockCustomerFindOne.mockReset();
|
|
45
|
+
mockSubscriptionFindAll.mockReset().mockResolvedValue([]);
|
|
46
|
+
mockCreditGrantFindAll.mockReset().mockResolvedValue([]);
|
|
47
|
+
mockPaymentMethodFindByPk.mockReset();
|
|
48
|
+
mockPriceFindAll.mockReset().mockResolvedValue([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('checkEntitlement', () => {
|
|
52
|
+
it('returns inactive when customer not found', async () => {
|
|
53
|
+
mockCustomerFindOne.mockResolvedValue(null);
|
|
54
|
+
const result = await checkEntitlement({ customer_did: 'did:unknown', product_id: 'pro_monthly' });
|
|
55
|
+
expect(result).toEqual({
|
|
56
|
+
active: false,
|
|
57
|
+
channel: null,
|
|
58
|
+
expires_at: null,
|
|
59
|
+
subscription_id: null,
|
|
60
|
+
source: null,
|
|
61
|
+
});
|
|
62
|
+
expect(mockSubscriptionFindAll).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns active result for active subscription', async () => {
|
|
66
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
67
|
+
mockSubscriptionFindAll.mockResolvedValue([makeSub()]);
|
|
68
|
+
|
|
69
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'pro_monthly' });
|
|
70
|
+
expect(result.active).toBe(true);
|
|
71
|
+
expect(result.subscription_id).toBe('sub_1');
|
|
72
|
+
expect(result.channel).toBe('app_store');
|
|
73
|
+
expect(result.source).toBe('subscription');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns inactive=true subscription_id set for past_due (covers grace period)', async () => {
|
|
77
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
78
|
+
mockSubscriptionFindAll.mockResolvedValue([makeSub({ status: 'past_due' })]);
|
|
79
|
+
|
|
80
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'pro_monthly' });
|
|
81
|
+
expect(result.active).toBe(false);
|
|
82
|
+
expect(result.subscription_id).toBe('sub_1');
|
|
83
|
+
expect(result.source).toBe('subscription');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('picks active over trialing over paused when multiple subs cover the same product', async () => {
|
|
87
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
88
|
+
mockSubscriptionFindAll.mockResolvedValue([
|
|
89
|
+
makeSub({ id: 'sub_paused', status: 'paused' }),
|
|
90
|
+
makeSub({ id: 'sub_active', status: 'active' }),
|
|
91
|
+
makeSub({ id: 'sub_trial', status: 'trialing' }),
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'pro_monthly' });
|
|
95
|
+
expect(result.subscription_id).toBe('sub_active');
|
|
96
|
+
expect(result.active).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('breaks ties on equal status by latest current_period_end', async () => {
|
|
100
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
101
|
+
mockSubscriptionFindAll.mockResolvedValue([
|
|
102
|
+
makeSub({ id: 'sub_older', status: 'active', current_period_end: 1700000000 }),
|
|
103
|
+
makeSub({ id: 'sub_newer', status: 'active', current_period_end: 1800000000 }),
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'pro_monthly' });
|
|
107
|
+
expect(result.subscription_id).toBe('sub_newer');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('skips subscriptions whose items do not cover the target product', async () => {
|
|
111
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
112
|
+
mockSubscriptionFindAll.mockResolvedValue([makeSub({ items: [{ price: { product_id: 'other_product' } }] })]);
|
|
113
|
+
mockCreditGrantFindAll.mockResolvedValue([]);
|
|
114
|
+
|
|
115
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'pro_monthly' });
|
|
116
|
+
expect(result.active).toBe(false);
|
|
117
|
+
expect(result.subscription_id).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('resolves IAP subscription live via Product channel mapping (no items, empty metadata.product_id)', async () => {
|
|
121
|
+
// Mirrors the real app_store case: subscription has no SubscriptionItems and
|
|
122
|
+
// an empty metadata.product_id (Product mapping was added after purchase),
|
|
123
|
+
// but reliably stores the channel SKU + bundle_id in payment_details.app_store.
|
|
124
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
125
|
+
mockSubscriptionFindAll.mockResolvedValue([
|
|
126
|
+
makeSub({
|
|
127
|
+
id: 'sub_iap',
|
|
128
|
+
channel: 'app_store',
|
|
129
|
+
items: [],
|
|
130
|
+
metadata: {},
|
|
131
|
+
payment_details: { app_store: { product_id: 'pk_demo_monthly', bundle_id: 'com.example.app' } },
|
|
132
|
+
}),
|
|
133
|
+
]);
|
|
134
|
+
// Querying the local Product id; its Price.metadata maps to the App Store SKU
|
|
135
|
+
// (Stripe-style — SKU binding lives on Price, not Product). Multi-tenant:
|
|
136
|
+
// bundle_id is part of the lookup key so two apps with the same SKU string
|
|
137
|
+
// don't collide.
|
|
138
|
+
mockPriceFindAll.mockResolvedValue([
|
|
139
|
+
{ product_id: 'prod_X', metadata: { app_store_product_id: 'pk_demo_monthly', bundle_id: 'com.example.app' } },
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'prod_X' });
|
|
143
|
+
expect(result.active).toBe(true);
|
|
144
|
+
expect(result.subscription_id).toBe('sub_iap');
|
|
145
|
+
expect(result.source).toBe('subscription');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('does NOT match when the Price channel SKU differs from the subscription SKU', async () => {
|
|
149
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
150
|
+
mockSubscriptionFindAll.mockResolvedValue([
|
|
151
|
+
makeSub({
|
|
152
|
+
id: 'sub_iap',
|
|
153
|
+
items: [],
|
|
154
|
+
metadata: {},
|
|
155
|
+
payment_details: { app_store: { product_id: 'pk_demo_monthly', bundle_id: 'com.example.app' } },
|
|
156
|
+
}),
|
|
157
|
+
]);
|
|
158
|
+
mockPriceFindAll.mockResolvedValue([
|
|
159
|
+
{ product_id: 'prod_Y', metadata: { app_store_product_id: 'pk_other_sku', bundle_id: 'com.example.app' } },
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'prod_Y' });
|
|
163
|
+
expect(result.active).toBe(false);
|
|
164
|
+
expect(result.subscription_id).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('IAP SKU rebind does not double-grant — live mapping wins over a stale SubscriptionItem', async () => {
|
|
168
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
169
|
+
// app_store sub: stale item points to prod_A, but the stored channel SKU is 'sku1'.
|
|
170
|
+
mockSubscriptionFindAll.mockResolvedValue([
|
|
171
|
+
makeSub({
|
|
172
|
+
channel: 'app_store',
|
|
173
|
+
items: [{ price: { product_id: 'prod_A' } }],
|
|
174
|
+
payment_details: { app_store: { product_id: 'sku1', bundle_id: 'com.example.app' } },
|
|
175
|
+
}),
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
// Query prod_A: no Price for it bound to sku1 → must be inactive (stale
|
|
179
|
+
// item ignored).
|
|
180
|
+
mockPriceFindAll.mockResolvedValue([
|
|
181
|
+
{ product_id: 'prod_A', metadata: { app_store_product_id: 'other_sku', bundle_id: 'com.example.app' } },
|
|
182
|
+
]);
|
|
183
|
+
const a = await checkEntitlement({ customer_did: 'did:1', product_id: 'prod_A' });
|
|
184
|
+
expect(a.active).toBe(false);
|
|
185
|
+
expect(a.subscription_id).toBeNull();
|
|
186
|
+
|
|
187
|
+
// Query prod_B: a Price under prod_B is now bound to sku1 → active via
|
|
188
|
+
// live mapping.
|
|
189
|
+
mockPriceFindAll.mockResolvedValue([
|
|
190
|
+
{ product_id: 'prod_B', metadata: { app_store_product_id: 'sku1', bundle_id: 'com.example.app' } },
|
|
191
|
+
]);
|
|
192
|
+
const b = await checkEntitlement({ customer_did: 'did:1', product_id: 'prod_B' });
|
|
193
|
+
expect(b.active).toBe(true);
|
|
194
|
+
expect(b.subscription_id).toBe('sub_1');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('falls back to PaymentMethod.type when Subscription.channel is unset', async () => {
|
|
198
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
199
|
+
mockSubscriptionFindAll.mockResolvedValue([makeSub({ channel: undefined })]);
|
|
200
|
+
mockPaymentMethodFindByPk.mockResolvedValue({ type: 'stripe' });
|
|
201
|
+
|
|
202
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'pro_monthly' });
|
|
203
|
+
expect(result.channel).toBe('stripe');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('falls back to CreditGrant when no subscription matches', async () => {
|
|
207
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
208
|
+
mockSubscriptionFindAll.mockResolvedValue([]);
|
|
209
|
+
mockCreditGrantFindAll.mockResolvedValue([makeGrant({ metadata: { product_id: 'pro_monthly' } })]);
|
|
210
|
+
mockPaymentMethodFindByPk.mockResolvedValue({ type: 'stripe' });
|
|
211
|
+
|
|
212
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'pro_monthly' });
|
|
213
|
+
expect(result.active).toBe(true);
|
|
214
|
+
expect(result.source).toBe('one_time');
|
|
215
|
+
expect(result.credit_remaining).toBe('100');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('CreditGrant with remaining_amount=0 is inactive', async () => {
|
|
219
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
220
|
+
mockSubscriptionFindAll.mockResolvedValue([]);
|
|
221
|
+
mockCreditGrantFindAll.mockResolvedValue([
|
|
222
|
+
makeGrant({ metadata: { product_id: 'pro_monthly' }, remaining_amount: '0' }),
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'pro_monthly' });
|
|
226
|
+
expect(result.active).toBe(false);
|
|
227
|
+
expect(result.source).toBe('one_time');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('CreditGrant past expires_at is inactive', async () => {
|
|
231
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
232
|
+
mockSubscriptionFindAll.mockResolvedValue([]);
|
|
233
|
+
mockCreditGrantFindAll.mockResolvedValue([
|
|
234
|
+
makeGrant({ metadata: { product_id: 'pro_monthly' }, expires_at: 1000 }),
|
|
235
|
+
]);
|
|
236
|
+
|
|
237
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'pro_monthly' });
|
|
238
|
+
expect(result.active).toBe(false);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('listEntitlements', () => {
|
|
243
|
+
it('returns [] when customer not found', async () => {
|
|
244
|
+
mockCustomerFindOne.mockResolvedValue(null);
|
|
245
|
+
expect(await listEntitlements({ customer_did: 'did:unknown' })).toEqual([]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('returns one row per distinct product, picking best subscription', async () => {
|
|
249
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
250
|
+
mockSubscriptionFindAll.mockResolvedValue([
|
|
251
|
+
makeSub({
|
|
252
|
+
id: 'sub_pro',
|
|
253
|
+
status: 'active',
|
|
254
|
+
items: [{ price: { product_id: 'pro_monthly' } }],
|
|
255
|
+
}),
|
|
256
|
+
makeSub({
|
|
257
|
+
id: 'sub_paused_pro',
|
|
258
|
+
status: 'paused',
|
|
259
|
+
items: [{ price: { product_id: 'pro_monthly' } }],
|
|
260
|
+
}),
|
|
261
|
+
makeSub({
|
|
262
|
+
id: 'sub_basic',
|
|
263
|
+
status: 'trialing',
|
|
264
|
+
items: [{ price: { product_id: 'basic' } }],
|
|
265
|
+
}),
|
|
266
|
+
]);
|
|
267
|
+
|
|
268
|
+
const list = await listEntitlements({ customer_did: 'did:1' });
|
|
269
|
+
expect(list).toHaveLength(2);
|
|
270
|
+
const pro = list.find((x) => x.product_id === 'pro_monthly');
|
|
271
|
+
expect(pro?.subscription_id).toBe('sub_pro');
|
|
272
|
+
const basic = list.find((x) => x.product_id === 'basic');
|
|
273
|
+
expect(basic?.subscription_id).toBe('sub_basic');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('merges CreditGrant entitlements for products not covered by subscriptions', async () => {
|
|
277
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
278
|
+
mockSubscriptionFindAll.mockResolvedValue([
|
|
279
|
+
makeSub({ id: 'sub_pro', items: [{ price: { product_id: 'pro_monthly' } }] }),
|
|
280
|
+
]);
|
|
281
|
+
mockCreditGrantFindAll.mockResolvedValue([
|
|
282
|
+
makeGrant({ metadata: { product_id: 'one_off' } }),
|
|
283
|
+
// Already covered by subscription — should NOT duplicate
|
|
284
|
+
makeGrant({ id: 'cg_dup', metadata: { product_id: 'pro_monthly' } }),
|
|
285
|
+
]);
|
|
286
|
+
mockPaymentMethodFindByPk.mockResolvedValue({ type: 'stripe' });
|
|
287
|
+
|
|
288
|
+
const list = await listEntitlements({ customer_did: 'did:1' });
|
|
289
|
+
expect(list).toHaveLength(2);
|
|
290
|
+
const oneOff = list.find((x) => x.source === 'one_time');
|
|
291
|
+
expect(oneOff?.product_id).toBe('one_off');
|
|
292
|
+
expect(oneOff?.active).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('lists an IAP subscription under its live-mapped product, not a stale item', async () => {
|
|
296
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
297
|
+
mockSubscriptionFindAll.mockResolvedValue([
|
|
298
|
+
makeSub({
|
|
299
|
+
channel: 'app_store',
|
|
300
|
+
items: [{ price: { product_id: 'prod_A_stale' } }],
|
|
301
|
+
payment_details: { app_store: { product_id: 'sku1', bundle_id: 'com.example.app' } },
|
|
302
|
+
}),
|
|
303
|
+
]);
|
|
304
|
+
// Live catalogue: a Price under prod_B is bound to sku1.
|
|
305
|
+
mockPriceFindAll.mockResolvedValue([
|
|
306
|
+
{ product_id: 'prod_B', metadata: { app_store_product_id: 'sku1', bundle_id: 'com.example.app' } },
|
|
307
|
+
]);
|
|
308
|
+
|
|
309
|
+
const list = await listEntitlements({ customer_did: 'did:1' });
|
|
310
|
+
expect(list).toHaveLength(1);
|
|
311
|
+
expect(list[0]!.product_id).toBe('prod_B');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('multi-tenant: same SKU string across two apps does not grant the wrong product (entitlement leak)', async () => {
|
|
315
|
+
// Two iOS apps, both legitimately using SKU 'pro_monthly' in their own
|
|
316
|
+
// App Store Connect namespaces (Apple SKUs are per-bundle-id, not global).
|
|
317
|
+
// App A's customer has a sub for App A's pro_monthly; App B has its own
|
|
318
|
+
// distinct Product/Price. Without (sku, bundle_id) scoping in entitlement.ts,
|
|
319
|
+
// App A's sub would incorrectly grant App B's product too.
|
|
320
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_appA_user' });
|
|
321
|
+
mockSubscriptionFindAll.mockResolvedValue([
|
|
322
|
+
makeSub({
|
|
323
|
+
channel: 'app_store',
|
|
324
|
+
items: [],
|
|
325
|
+
metadata: {},
|
|
326
|
+
payment_details: { app_store: { product_id: 'pro_monthly', bundle_id: 'com.appA' } },
|
|
327
|
+
}),
|
|
328
|
+
]);
|
|
329
|
+
// Querying App B's Product, whose Price uses the same SKU string under a
|
|
330
|
+
// different bundle_id — must NOT match App A's sub.
|
|
331
|
+
mockPriceFindAll.mockResolvedValue([
|
|
332
|
+
{ product_id: 'prod_appB_pro', metadata: { app_store_product_id: 'pro_monthly', bundle_id: 'com.appB' } },
|
|
333
|
+
]);
|
|
334
|
+
|
|
335
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'prod_appB_pro' });
|
|
336
|
+
expect(result.active).toBe(false);
|
|
337
|
+
expect(result.subscription_id).toBeNull();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('skips CreditGrants without product_id metadata', async () => {
|
|
341
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
342
|
+
mockSubscriptionFindAll.mockResolvedValue([]);
|
|
343
|
+
mockCreditGrantFindAll.mockResolvedValue([makeGrant({ metadata: {} })]);
|
|
344
|
+
|
|
345
|
+
expect(await listEntitlements({ customer_did: 'did:1' })).toEqual([]);
|
|
346
|
+
});
|
|
347
|
+
});
|