@shware/purchase 5.1.1 → 5.2.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/README.md ADDED
@@ -0,0 +1,341 @@
1
+ # @shware/purchase
2
+
3
+ Shared building blocks for selling subscriptions and credits on Stripe, the App Store and Google
4
+ Play. The root export holds a platform-neutral subscription model (statuses, cancellation details)
5
+ and the three product catalogs (`StripeConfig`, `AppStoreConfig`, `GooglePlayConfig`) that map
6
+ store product ids to your plans, billing periods and credit grants. The platform subpaths add
7
+ typed event dispatchers and mappers from each store's payloads onto that model. Verifying webhook
8
+ signatures and calling store APIs stays in your app, with the official SDKs.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ pnpm add @shware/purchase zod
14
+ # optional peers, only for the subpaths you use
15
+ pnpm add stripe # @shware/purchase/stripe (^22.6.2 || ^23.0.0)
16
+ pnpm add @apple/app-store-server-library # @shware/purchase/app-store (^3.1.0)
17
+ ```
18
+
19
+ ## Entry points
20
+
21
+ | Import path | Contents |
22
+ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
23
+ | `@shware/purchase` | Subscription model (`PLATFORMS`, `SUBSCRIPTION_STATUSES`, `AVAILABLE_STATUSES`, cancellation types) and the three configs |
24
+ | `@shware/purchase/stripe` | `StripeEventHandler`, Stripe mappers, checkout schemas, `METADATA_KEYS`, analytics helpers, browser pending-checkout helpers |
25
+ | `@shware/purchase/app-store` | `AppStoreNotificationHandler` for App Store Server Notifications V2, `mapCancellationDetails` |
26
+ | `@shware/purchase/google-play` | Real-time developer notification (RTDN) types and enums, subscription state and cancellation mappers |
27
+
28
+ ## Subscription model
29
+
30
+ ```ts
31
+ import {
32
+ AVAILABLE_STATUSES,
33
+ PLATFORMS,
34
+ SUBSCRIPTION_STATUSES,
35
+ type CancellationDetails,
36
+ type Platform,
37
+ type SubscriptionStatus,
38
+ } from '@shware/purchase';
39
+ ```
40
+
41
+ - `PLATFORMS`: `'APPLE' | 'GOOGLE' | 'STRIPE'`.
42
+ - `SUBSCRIPTION_STATUSES`: the union of every store's states (`TRIALING`, `ACTIVE`,
43
+ `IN_GRACE_PERIOD`, `PAST_DUE`, `ON_HOLD`, `PAUSED`, `CANCELED`, `EXPIRED`, ...). Each platform
44
+ mapper returns one of these.
45
+ - `AVAILABLE_STATUSES`: `ACTIVE`, `TRIALING`, `IN_GRACE_PERIOD`, the statuses that grant access.
46
+ - `CancellationDetails`: why a subscription stopped renewing, one record for all platforms:
47
+ `initiator` (`CANCELLATION_INITIATORS`: `user`, `system`, `developer`), the store's own `reason`,
48
+ `feedback` (`CANCELLATION_FEEDBACKS`), `comment` and `resubscribeIntent` (`RESUBSCRIBE_INTENTS`).
49
+
50
+ The constants are `as const` arrays, so they can back database enums directly:
51
+
52
+ ```ts
53
+ import { PLATFORMS, SUBSCRIPTION_STATUSES } from '@shware/purchase';
54
+ import { pgEnum } from 'drizzle-orm/pg-core';
55
+
56
+ export const platform = pgEnum('platform', PLATFORMS);
57
+ export const subscriptionStatus = pgEnum('subscription_status', SUBSCRIPTION_STATUSES);
58
+ ```
59
+
60
+ ```ts
61
+ const entitled = (AVAILABLE_STATUSES as readonly SubscriptionStatus[]).includes(sub.status);
62
+ ```
63
+
64
+ ## Product catalogs
65
+
66
+ Each config is built with a fluent, type-checked chain. Type parameters are the product id
67
+ namespace, your plan type and your billing period type; product ids must start with the namespace,
68
+ and duplicate products or plan/period pairs fail at compile time and at runtime. Credit metadata
69
+ is `{ credits: number; expiresIn: string }` where `expiresIn` is an
70
+ [`ms`](https://github.com/vercel/ms) duration.
71
+
72
+ ```ts
73
+ import { AppStoreConfig, GooglePlayConfig, StripeConfig } from '@shware/purchase';
74
+
75
+ type Plan = 'STARTER' | 'PRO';
76
+ type BillingPeriod = 'MONTHLY' | 'YEARLY';
77
+
78
+ export const stripe = StripeConfig.create<'com.example.stripe', Plan, BillingPeriod>({
79
+ mode: env.STRIPE_SECRET_KEY.startsWith('sk_test_') ? 'test' : 'live',
80
+ allowPromotionCodes: false,
81
+ cancellationCouponId: 'retention_30pct_3mo',
82
+ returnUrl: 'https://example.com',
83
+ cancelUrl: 'https://example.com',
84
+ successUrl: 'https://example.com/payments/success?session_id={CHECKOUT_SESSION_ID}',
85
+ })
86
+ // one-time product: no plan or period
87
+ .product('com.example.stripe.credit.starter')
88
+ .price('price_live_credits', { credits: 10, expiresIn: '1 year' })
89
+ .default('price_live_credits', { test: 'price_test_credits' })
90
+ // subscription: plan + billing period
91
+ .product('com.example.stripe.sub.monthly.starter', 'STARTER', 'MONTHLY')
92
+ .price('price_live_starter_v2', { credits: 100, expiresIn: '30 days' })
93
+ .price('price_live_starter_v1', { credits: 100, expiresIn: '30 days' }) // grandfathered
94
+ .default('price_live_starter_v2', { test: 'price_test_starter' });
95
+
96
+ export const appStore = AppStoreConfig.create<'com.example.appstore', Plan, BillingPeriod>({
97
+ appId: '1234567890',
98
+ bundleId: 'com.example.app',
99
+ })
100
+ .consumable('com.example.appstore.credit.starter', { credits: 10, expiresIn: '365d' })
101
+ .subscription('STARTER', 'MONTHLY')
102
+ .product('com.example.appstore.sub.monthly.starter.v1', { credits: 100, expiresIn: '30d' })
103
+ .default('com.example.appstore.sub.monthly.starter.v1');
104
+
105
+ export const googlePlay = GooglePlayConfig.create<'com.example.googleplay', Plan, BillingPeriod>({
106
+ package: 'com.example.app',
107
+ audience: 'api.example.com', // expected audience of the RTDN push OIDC token
108
+ })
109
+ .subscription('com.example.googleplay.sub.starter')
110
+ .basePlan('monthly-v1', 'STARTER', 'MONTHLY', { credits: 100, expiresIn: '30d' })
111
+ .basePlan('yearly-v1', 'STARTER', 'YEARLY', { credits: 1200, expiresIn: '365d' })
112
+ .default(['monthly-v1', 'yearly-v1'])
113
+ .onetime('com.example.googleplay.credit.starter', { credits: 10, expiresIn: '365d' });
114
+ ```
115
+
116
+ Lookups:
117
+
118
+ | Config | Methods |
119
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
120
+ | `StripeConfig` | `productIds`, `getPriceId(productId)`, `getPlan`, `getBillingPeriod`, `getMode`, `getCreditAmount(productId, priceId?)`, `getCreditExpiresAt(productId, priceId?)` |
121
+ | `AppStoreConfig` | `productIds`, `getDefaultProductId(plan, billingPeriod)`, `getPlan`, `getBillingPeriod`, `getMode`, `getCreditAmount`, `getCreditExpiresAt` |
122
+ | `GooglePlayConfig` | `productIds`, `getDefaultPlanIds(productId)`, `getPlan(productId, planId)`, `getBillingPeriod(productId, planId)`, `getMode`, `getCreditAmount(productId, planId?)`, `getCreditExpiresAt(productId, planId?)` |
123
+
124
+ In Stripe test mode the `test` twin of the default price is what `getPriceId` returns, and it
125
+ resolves to the default price's credits. Non-default prices, products and base plans stay declared
126
+ so existing subscribers keep resolving.
127
+
128
+ ## Stripe
129
+
130
+ `@shware/purchase/stripe` (requires `stripe`).
131
+
132
+ ### Webhooks
133
+
134
+ Verify the signature with the Stripe SDK, then dispatch with `StripeEventHandler`. `on` is typed
135
+ per event, so each handler receives the right `event.data.object`; `process` throws for an event
136
+ type with no handler.
137
+
138
+ ```ts
139
+ import {
140
+ METADATA_KEYS,
141
+ StripeEventHandler,
142
+ mapCancellationDetails,
143
+ mapSubscriptionStatus,
144
+ mapTime,
145
+ } from '@shware/purchase/stripe';
146
+ import Stripe from 'stripe';
147
+
148
+ const stripe = new Stripe(env.STRIPE_SECRET_KEY);
149
+
150
+ export async function processStripeEvent(signature: string, body: string) {
151
+ const event = await stripe.webhooks.constructEventAsync(
152
+ body,
153
+ signature,
154
+ env.STRIPE_WEBHOOK_SECRET
155
+ );
156
+
157
+ await StripeEventHandler.create()
158
+ .on('checkout.session.completed', handleCheckoutSessionCompleted)
159
+ .on('invoice.payment_succeeded', handleInvoicePaymentSucceeded)
160
+ .on('customer.subscription.created', handleSubscriptionEvent)
161
+ .on('customer.subscription.updated', handleSubscriptionEvent)
162
+ .on('customer.subscription.deleted', handleSubscriptionEvent)
163
+ .process(event);
164
+ }
165
+
166
+ async function handleSubscriptionEvent(sub: Stripe.Subscription) {
167
+ const userId = sub.metadata[METADATA_KEYS.USER_ID];
168
+ const productId = sub.metadata[METADATA_KEYS.PRODUCT_ID];
169
+ await saveSubscription({
170
+ userId,
171
+ platform: 'STRIPE',
172
+ plan: config.stripe.getPlan(productId),
173
+ billingPeriod: config.stripe.getBillingPeriod(productId),
174
+ status: mapSubscriptionStatus(sub.status),
175
+ startedAt: mapTime(sub.start_date),
176
+ cancellationDetails: mapCancellationDetails(sub),
177
+ });
178
+ }
179
+ ```
180
+
181
+ ### Checkout
182
+
183
+ `createCheckoutSessionSchema(productIds)` is a `zod/mini` schema for the create-checkout request
184
+ (`productId`, `quantity` default 1, optional `cancelUrl` / `successUrl`). `METADATA_KEYS` names
185
+ the metadata fields (`user_id`, `product_id`, `referral_code`, ...) the webhook reads back.
186
+
187
+ ```ts
188
+ import { zValidator } from '@hono/zod-validator';
189
+ import { METADATA_KEYS, createCheckoutSessionSchema } from '@shware/purchase/stripe';
190
+
191
+ app.post(
192
+ '/v1/checkout/sessions',
193
+ zValidator('json', createCheckoutSessionSchema(config.stripe.productIds)),
194
+ async (c) => {
195
+ const { productId, quantity, cancelUrl, successUrl } = c.req.valid('json');
196
+ const userId = await getUserId(c);
197
+ const session = await stripe.checkout.sessions.create({
198
+ mode: config.stripe.getMode(productId),
199
+ line_items: [{ price: config.stripe.getPriceId(productId), quantity }],
200
+ metadata: { [METADATA_KEYS.USER_ID]: userId, [METADATA_KEYS.PRODUCT_ID]: productId },
201
+ allow_promotion_codes: config.stripe.allowPromotionCodes,
202
+ success_url: successUrl ?? config.stripe.successUrl,
203
+ cancel_url: cancelUrl ?? config.stripe.cancelUrl,
204
+ });
205
+ return c.json({ id: session.id, url: session.url });
206
+ }
207
+ );
208
+ ```
209
+
210
+ `cancellationDetailsSchema` validates what your own cancel dialog sends (`comment`, `feedback`,
211
+ `resubscribeIntent`).
212
+
213
+ ### Mappers
214
+
215
+ Plain-JSON views of Stripe objects for API responses: `mapCheckoutSession`, `mapLineItem`,
216
+ `mapPrice`, `mapInvoice`, `mapPaymentIntent`, `mapCharge`, plus `mapSubscriptionStatus`,
217
+ `mapCancellationDetails` and `mapTime` (Unix seconds to ISO string, `null` passes through).
218
+
219
+ ### Browser helpers
220
+
221
+ These run in the browser and have no runtime dependency on `stripe`:
222
+
223
+ - `price(value, currency)` / `minorUnits(currency)`: convert minor units to a display amount,
224
+ aware of zero-decimal currencies.
225
+ - `getBeginCheckoutProperties(productPrice)` and `getPurchaseProperties(checkoutSession)`:
226
+ GA4-style `begin_checkout` / `purchase` event properties.
227
+ - `addPendingCheckout(sessionId)`, `getPendingCheckouts()`, `removePendingCheckout(sessionId)`:
228
+ a `localStorage` list (one-hour TTL) of checkouts to re-confirm if the user closed the tab before
229
+ returning to the success page.
230
+
231
+ ```ts
232
+ import {
233
+ addPendingCheckout,
234
+ getPendingCheckouts,
235
+ removePendingCheckout,
236
+ } from '@shware/purchase/stripe';
237
+
238
+ // before redirecting to Stripe
239
+ addPendingCheckout(session.id);
240
+
241
+ // on app start
242
+ for (const sessionId of getPendingCheckouts()) {
243
+ await confirmCheckoutSession(sessionId);
244
+ removePendingCheckout(sessionId);
245
+ }
246
+ ```
247
+
248
+ ## App Store
249
+
250
+ `@shware/purchase/app-store` (requires `@apple/app-store-server-library`).
251
+
252
+ Decode and verify the notification with Apple's `SignedDataVerifier`, then dispatch the decoded
253
+ event by `notificationType` with `AppStoreNotificationHandler`. Branch on `subtype` inside each
254
+ handler.
255
+
256
+ ```ts
257
+ import { NotificationTypeV2, type SignedDataVerifier } from '@apple/app-store-server-library';
258
+ import {
259
+ type AppStoreNotificationEvent,
260
+ AppStoreNotificationHandler,
261
+ mapCancellationDetails,
262
+ } from '@shware/purchase/app-store';
263
+
264
+ export async function processAppStoreNotification(verifier: SignedDataVerifier, body: string) {
265
+ const payload = await verifier.verifyAndDecodeNotification(body);
266
+ const { signedTransactionInfo, signedRenewalInfo } = payload.data ?? {};
267
+ const transaction = signedTransactionInfo
268
+ ? await verifier.verifyAndDecodeTransaction(signedTransactionInfo)
269
+ : null;
270
+ const renewalInfo = signedRenewalInfo
271
+ ? await verifier.verifyAndDecodeRenewalInfo(signedRenewalInfo)
272
+ : null;
273
+
274
+ await AppStoreNotificationHandler.create()
275
+ .on(NotificationTypeV2.SUBSCRIBED, handleSubscribed)
276
+ .on(NotificationTypeV2.DID_RENEW, handleRenewed)
277
+ .on(NotificationTypeV2.DID_CHANGE_RENEWAL_STATUS, handleRenewalStatusChanged)
278
+ .on(NotificationTypeV2.EXPIRED, handleExpired)
279
+ .on(NotificationTypeV2.REFUND, handleRefunded)
280
+ .process({ payload, transaction, renewalInfo });
281
+ }
282
+
283
+ async function handleRenewalStatusChanged({ payload, renewalInfo }: AppStoreNotificationEvent) {
284
+ const cancellationDetails = mapCancellationDetails({
285
+ notificationType: payload.notificationType!,
286
+ subtype: payload.subtype,
287
+ expirationIntent: renewalInfo?.expirationIntent,
288
+ });
289
+ // null when auto-renew was turned back on
290
+ }
291
+ ```
292
+
293
+ Map `transaction.productId` to your plan with `config.appStore.getPlan(productId)` and
294
+ `getBillingPeriod(productId)`, and grant credits with `getCreditAmount` / `getCreditExpiresAt`.
295
+
296
+ ## Google Play
297
+
298
+ `@shware/purchase/google-play` (no peer dependency).
299
+
300
+ It provides the RTDN payload types (`PubsubMessage`, `DeveloperNotification`,
301
+ `SubscriptionNotification`, `OneTimeProductNotification`, `VoidedPurchaseNotification`,
302
+ `TestNotification`), the numeric enums (`SubscriptionNotificationType`,
303
+ `OneTimeProductNotificationType`, `VoidedPurchaseProductType`, `VoidedPurchaseRefundType`), and
304
+ mappers for the `purchases.subscriptionsv2` resource. Verify the Pub/Sub push token (its audience
305
+ is `config.googlePlay.audience`) and fetch the purchase with the Android Publisher API yourself.
306
+
307
+ ```ts
308
+ import {
309
+ type DeveloperNotification,
310
+ type GooglePlaySubscriptionState,
311
+ type PubsubMessage,
312
+ mapCanceledAt,
313
+ mapCancellationDetails,
314
+ mapSubscriptionStatus,
315
+ } from '@shware/purchase/google-play';
316
+
317
+ export async function processGooglePlayNotification(authorization: string, body: string) {
318
+ await verifyIdToken(authorization.replace('Bearer ', '')); // your OIDC check
319
+
320
+ const { message } = JSON.parse(body) as { message: PubsubMessage };
321
+ const notification = JSON.parse(
322
+ Buffer.from(message.data, 'base64').toString('utf-8')
323
+ ) as Partial<DeveloperNotification>;
324
+
325
+ if (notification.subscriptionNotification) {
326
+ const { purchaseToken } = notification.subscriptionNotification;
327
+ const { data } = await publisher.purchases.subscriptionsv2.get({
328
+ packageName: config.googlePlay.package,
329
+ token: purchaseToken,
330
+ });
331
+ const status = mapSubscriptionStatus(data.subscriptionState as GooglePlaySubscriptionState);
332
+ const cancellationDetails = mapCancellationDetails(data.canceledStateContext);
333
+ const canceledAt = mapCanceledAt(data.canceledStateContext);
334
+ // save status, cancellationDetails, canceledAt ...
335
+ }
336
+ }
337
+ ```
338
+
339
+ Resolve the plan from a line item with `config.googlePlay.getPlan(productId, basePlanId)` and
340
+ `getBillingPeriod(productId, basePlanId)`. `GOOGLE_PLAY_SUBSCRIPTION_STATES` lists the raw
341
+ `SUBSCRIPTION_STATE_*` values.
@@ -45,4 +45,5 @@ export declare class AppStoreConfig<NS extends Lowercase<string> = Lowercase<str
45
45
  getCreditExpiresAt: (productId: string) => string;
46
46
  }
47
47
  //#endregion
48
+ export {};
48
49
  //# sourceMappingURL=config.d.cts.map
@@ -45,4 +45,5 @@ export declare class AppStoreConfig<NS extends Lowercase<string> = Lowercase<str
45
45
  getCreditExpiresAt: (productId: string) => string;
46
46
  }
47
47
  //#endregion
48
+ export {};
48
49
  //# sourceMappingURL=config.d.mts.map
@@ -20,4 +20,5 @@ export declare class AppStoreNotificationHandler {
20
20
  process: (event: AppStoreNotificationEvent) => Promise<void>;
21
21
  }
22
22
  //#endregion
23
+ export {};
23
24
  //# sourceMappingURL=handler.d.cts.map
@@ -20,4 +20,5 @@ export declare class AppStoreNotificationHandler {
20
20
  process: (event: AppStoreNotificationEvent) => Promise<void>;
21
21
  }
22
22
  //#endregion
23
+ export {};
23
24
  //# sourceMappingURL=handler.d.mts.map
@@ -44,4 +44,5 @@ export declare class GooglePlayConfig<NS extends Lowercase<string> = Lowercase<s
44
44
  getCreditExpiresAt: (productId: string, planId?: string) => string;
45
45
  }
46
46
  //#endregion
47
+ export {};
47
48
  //# sourceMappingURL=config.d.cts.map
@@ -44,4 +44,5 @@ export declare class GooglePlayConfig<NS extends Lowercase<string> = Lowercase<s
44
44
  getCreditExpiresAt: (productId: string, planId?: string) => string;
45
45
  }
46
46
  //#endregion
47
+ export {};
47
48
  //# sourceMappingURL=config.d.mts.map
@@ -73,4 +73,5 @@ export declare class StripeConfig<NS extends Lowercase<string> = Lowercase<strin
73
73
  getCreditExpiresAt: (productId: string, priceId?: string) => string;
74
74
  }
75
75
  //#endregion
76
+ export {};
76
77
  //# sourceMappingURL=config.d.cts.map
@@ -73,4 +73,5 @@ export declare class StripeConfig<NS extends Lowercase<string> = Lowercase<strin
73
73
  getCreditExpiresAt: (productId: string, priceId?: string) => string;
74
74
  }
75
75
  //#endregion
76
+ export {};
76
77
  //# sourceMappingURL=config.d.mts.map
@@ -12,4 +12,5 @@ export declare class StripeEventHandler {
12
12
  process: (event: Stripe.Event) => Promise<void>;
13
13
  }
14
14
  //#endregion
15
+ export {};
15
16
  //# sourceMappingURL=handler.d.cts.map
@@ -12,4 +12,5 @@ export declare class StripeEventHandler {
12
12
  process: (event: Stripe.Event) => Promise<void>;
13
13
  }
14
14
  //#endregion
15
+ export {};
15
16
  //# sourceMappingURL=handler.d.mts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shware/purchase",
3
- "version": "5.1.1",
3
+ "version": "5.2.1",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,20 +56,22 @@
56
56
  "access": "public"
57
57
  },
58
58
  "dependencies": {
59
- "@shware/utils": "^1.6.2",
59
+ "@shware/utils": "^1.7.0",
60
60
  "@types/ms": "^2.1.0",
61
- "ms": "^2.1.3",
62
- "zod": "^4.6.5"
61
+ "ms": "^2.1.3"
63
62
  },
64
63
  "devDependencies": {
65
64
  "@repo/typescript-config": "0.0.0",
66
- "@types/node": "^26.6.3",
65
+ "@types/node": "^26.6.5",
66
+ "stripe": "^23.0.0",
67
67
  "typescript": "^7.0.2",
68
- "vitest": "^5.0.2"
68
+ "vitest": "^5.0.5",
69
+ "zod": "^4.6.7"
69
70
  },
70
71
  "peerDependencies": {
71
72
  "@apple/app-store-server-library": "^3.1.0",
72
- "stripe": "^22.6.2"
73
+ "stripe": "^22.6.2 || ^23.0.0",
74
+ "zod": "^4.6.5"
73
75
  },
74
76
  "peerDependenciesMeta": {
75
77
  "@apple/app-store-server-library": {