@omg-dev/billing 0.4.41 → 0.4.42
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/dist/catalog.mjs +1 -4
- package/dist/index.mjs +20 -1
- package/package.json +1 -1
- package/src/catalog.ts +3 -5
- package/src/index.ts +63 -3
- package/src/test/billing.test.ts +58 -0
package/dist/catalog.mjs
CHANGED
|
@@ -74,10 +74,7 @@ function addOnPriceHash(addOn) {
|
|
|
74
74
|
function toCatalog(billing) {
|
|
75
75
|
return {
|
|
76
76
|
version: 1,
|
|
77
|
-
provider:
|
|
78
|
-
name: billing.provider.name,
|
|
79
|
-
useProviderMeters: false
|
|
80
|
-
},
|
|
77
|
+
provider: billing.provider,
|
|
81
78
|
credit: billing.credit,
|
|
82
79
|
features: billing.features.map((f) => ({
|
|
83
80
|
key: f.key,
|
package/dist/index.mjs
CHANGED
|
@@ -52,6 +52,13 @@ function polar() {
|
|
|
52
52
|
useProviderMeters: false
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
|
+
function appStore(input) {
|
|
56
|
+
return {
|
|
57
|
+
name: "app-store",
|
|
58
|
+
useProviderMeters: false,
|
|
59
|
+
...input
|
|
60
|
+
};
|
|
61
|
+
}
|
|
55
62
|
function plan(input) {
|
|
56
63
|
return {
|
|
57
64
|
__kind: "plan",
|
|
@@ -74,6 +81,18 @@ function defineBilling(config) {
|
|
|
74
81
|
const defaults = planKeys.filter((k) => config.plans[k].default);
|
|
75
82
|
if (defaults.length !== 1) throw new Error(`@omg-dev/billing: exactly one plan must be default:true (found ${defaults.length}: [${defaults.join(", ")}])`);
|
|
76
83
|
if (config.plans[defaults[0]].deprecated) throw new Error(`@omg-dev/billing: the default plan "${defaults[0]}" cannot be deprecated — new customers land on it`);
|
|
84
|
+
if (config.provider.name === "app-store") {
|
|
85
|
+
const provider = config.provider;
|
|
86
|
+
if (!provider.bundleId.trim()) throw new Error("@omg-dev/billing: app-store bundleId is required");
|
|
87
|
+
if (provider.environment === "Production" && (!Number.isSafeInteger(provider.appAppleId) || (provider.appAppleId ?? 0) <= 0)) throw new Error("@omg-dev/billing: app-store appAppleId is required in Production");
|
|
88
|
+
const productEntries = Object.entries(provider.products);
|
|
89
|
+
if (productEntries.length === 0) throw new Error("@omg-dev/billing: app-store products must not be empty");
|
|
90
|
+
for (const [productId, mapping] of productEntries) {
|
|
91
|
+
if (!productId.trim()) throw new Error("@omg-dev/billing: app-store productId must not be empty");
|
|
92
|
+
if (!config.plans[mapping.plan]) throw new Error(`@omg-dev/billing: app-store product "${productId}" maps to unknown plan "${mapping.plan}"`);
|
|
93
|
+
if (!Number.isSafeInteger(mapping.planVersion) || mapping.planVersion <= 0) throw new Error(`@omg-dev/billing: app-store product "${productId}" planVersion must be a positive integer`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
77
96
|
const rateCards = config.usage ?? {};
|
|
78
97
|
for (const fkey of Object.keys(rateCards)) {
|
|
79
98
|
const f = config.features[fkey];
|
|
@@ -151,4 +170,4 @@ function defineBilling(config) {
|
|
|
151
170
|
};
|
|
152
171
|
}
|
|
153
172
|
//#endregion
|
|
154
|
-
export { MICROS_PER_UNIT, addOn, defineBilling, formatMoney, limit, money, overage, plan, polar, rateCard, stripe, usd };
|
|
173
|
+
export { MICROS_PER_UNIT, addOn, appStore, defineBilling, formatMoney, limit, money, overage, plan, polar, rateCard, stripe, usd };
|
package/package.json
CHANGED
package/src/catalog.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
Money,
|
|
12
12
|
NormalizedAddOn,
|
|
13
13
|
NormalizedPlan,
|
|
14
|
+
ProviderDef,
|
|
14
15
|
RateCard,
|
|
15
16
|
} from "./index.ts";
|
|
16
17
|
|
|
@@ -60,7 +61,7 @@ export interface CatalogAddOn {
|
|
|
60
61
|
|
|
61
62
|
export interface Catalog {
|
|
62
63
|
version: 1;
|
|
63
|
-
provider:
|
|
64
|
+
provider: ProviderDef;
|
|
64
65
|
credit: { unit: string; peg: Money };
|
|
65
66
|
features: Array<{ key: string; kind: string; label: string; unit?: string }>;
|
|
66
67
|
rateCards: Record<string, RateCard>;
|
|
@@ -148,10 +149,7 @@ export function addOnPriceHash(addOn: NormalizedAddOn): string {
|
|
|
148
149
|
export function toCatalog(billing: Billing): Catalog {
|
|
149
150
|
return {
|
|
150
151
|
version: 1,
|
|
151
|
-
provider:
|
|
152
|
-
name: billing.provider.name,
|
|
153
|
-
useProviderMeters: false,
|
|
154
|
-
},
|
|
152
|
+
provider: billing.provider,
|
|
155
153
|
credit: billing.credit,
|
|
156
154
|
features: billing.features.map((f) => ({
|
|
157
155
|
key: f.key,
|
package/src/index.ts
CHANGED
|
@@ -124,14 +124,40 @@ export function rateCard(models: Record<string, ModelRate>): RateCard {
|
|
|
124
124
|
// Provider descriptors.
|
|
125
125
|
// ---------------------------------------------------------------------------
|
|
126
126
|
|
|
127
|
-
export type ProviderName = "stripe" | "polar";
|
|
127
|
+
export type ProviderName = "stripe" | "polar" | "app-store";
|
|
128
128
|
|
|
129
|
-
export interface
|
|
130
|
-
name:
|
|
129
|
+
export interface HostedProviderDef {
|
|
130
|
+
name: "stripe" | "polar";
|
|
131
131
|
/** Use provider-side meters? We deliberately keep this false — our ledger meters. */
|
|
132
132
|
useProviderMeters: false;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
export interface AppStoreProductDef {
|
|
136
|
+
/** OMG plan key granted by this App Store product. */
|
|
137
|
+
plan: string;
|
|
138
|
+
/** Catalog plan version represented by this immutable App Store product. */
|
|
139
|
+
planVersion: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Native StoreKit configuration. Apple is not a hosted-checkout provider.
|
|
144
|
+
* The control-plane verifies signed transactions and notifications, then
|
|
145
|
+
* writes their normalized lifecycle state into the shared entitlement engine.
|
|
146
|
+
*/
|
|
147
|
+
export interface AppStoreProviderDef {
|
|
148
|
+
name: "app-store";
|
|
149
|
+
useProviderMeters: false;
|
|
150
|
+
bundleId: string;
|
|
151
|
+
/** Numeric App Store Connect app id. Required for Production verification. */
|
|
152
|
+
appAppleId?: number;
|
|
153
|
+
environment: "Sandbox" | "Production";
|
|
154
|
+
/** Also accept verified Sandbox transactions while Production is live. */
|
|
155
|
+
allowSandbox?: boolean;
|
|
156
|
+
products: Record<string, AppStoreProductDef>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export type ProviderDef = HostedProviderDef | AppStoreProviderDef;
|
|
160
|
+
|
|
135
161
|
export function stripe(): ProviderDef {
|
|
136
162
|
return { name: "stripe", useProviderMeters: false };
|
|
137
163
|
}
|
|
@@ -140,6 +166,10 @@ export function polar(): ProviderDef {
|
|
|
140
166
|
return { name: "polar", useProviderMeters: false };
|
|
141
167
|
}
|
|
142
168
|
|
|
169
|
+
export function appStore(input: Omit<AppStoreProviderDef, "name" | "useProviderMeters">): AppStoreProviderDef {
|
|
170
|
+
return { name: "app-store", useProviderMeters: false, ...input };
|
|
171
|
+
}
|
|
172
|
+
|
|
143
173
|
// ---------------------------------------------------------------------------
|
|
144
174
|
// Plan definition (authoring shape).
|
|
145
175
|
// ---------------------------------------------------------------------------
|
|
@@ -329,6 +359,36 @@ export function defineBilling(config: BillingConfig): Billing {
|
|
|
329
359
|
);
|
|
330
360
|
}
|
|
331
361
|
|
|
362
|
+
if (config.provider.name === "app-store") {
|
|
363
|
+
const provider = config.provider;
|
|
364
|
+
if (!provider.bundleId.trim()) {
|
|
365
|
+
throw new Error("@omg-dev/billing: app-store bundleId is required");
|
|
366
|
+
}
|
|
367
|
+
if (provider.environment === "Production" &&
|
|
368
|
+
(!Number.isSafeInteger(provider.appAppleId) || (provider.appAppleId ?? 0) <= 0)) {
|
|
369
|
+
throw new Error("@omg-dev/billing: app-store appAppleId is required in Production");
|
|
370
|
+
}
|
|
371
|
+
const productEntries = Object.entries(provider.products);
|
|
372
|
+
if (productEntries.length === 0) {
|
|
373
|
+
throw new Error("@omg-dev/billing: app-store products must not be empty");
|
|
374
|
+
}
|
|
375
|
+
for (const [productId, mapping] of productEntries) {
|
|
376
|
+
if (!productId.trim()) {
|
|
377
|
+
throw new Error("@omg-dev/billing: app-store productId must not be empty");
|
|
378
|
+
}
|
|
379
|
+
if (!config.plans[mapping.plan]) {
|
|
380
|
+
throw new Error(
|
|
381
|
+
`@omg-dev/billing: app-store product "${productId}" maps to unknown plan "${mapping.plan}"`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
if (!Number.isSafeInteger(mapping.planVersion) || mapping.planVersion <= 0) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
`@omg-dev/billing: app-store product "${productId}" planVersion must be a positive integer`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
332
392
|
// rate cards may only reference declared metered features
|
|
333
393
|
const rateCards = config.usage ?? {};
|
|
334
394
|
for (const fkey of Object.keys(rateCards)) {
|
package/src/test/billing.test.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
rateCard,
|
|
9
9
|
stripe,
|
|
10
10
|
polar,
|
|
11
|
+
appStore,
|
|
11
12
|
usd,
|
|
12
13
|
money,
|
|
13
14
|
formatMoney,
|
|
@@ -155,6 +156,40 @@ describe("provider", () => {
|
|
|
155
156
|
expect(stripe().useProviderMeters).toBe(false);
|
|
156
157
|
expect(polar().useProviderMeters).toBe(false);
|
|
157
158
|
});
|
|
159
|
+
|
|
160
|
+
it("keeps native StoreKit configuration separate from hosted checkout", () => {
|
|
161
|
+
const provider = appStore({
|
|
162
|
+
bundleId: "dev.omg.example",
|
|
163
|
+
appAppleId: 123456789,
|
|
164
|
+
environment: "Production",
|
|
165
|
+
products: { "dev.omg.example.pro": { plan: "pro", planVersion: 2 } },
|
|
166
|
+
});
|
|
167
|
+
expect(provider.name).toBe("app-store");
|
|
168
|
+
expect(provider.products["dev.omg.example.pro"]).toEqual({ plan: "pro", planVersion: 2 });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("validates App Store identifiers and product mappings", () => {
|
|
172
|
+
const config = (provider: ReturnType<typeof appStore>) => ({
|
|
173
|
+
provider,
|
|
174
|
+
credit: { unit: "usd", peg: usd(1) },
|
|
175
|
+
features: { access: { kind: "boolean" as const } },
|
|
176
|
+
plans: {
|
|
177
|
+
free: plan({ price: usd(0), default: true, includes: { access: false } }),
|
|
178
|
+
pro: plan({ price: usd(10), interval: "month", includes: { access: true } }),
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(() => defineBilling(config(appStore({
|
|
183
|
+
bundleId: "dev.omg.example",
|
|
184
|
+
environment: "Production",
|
|
185
|
+
products: { "dev.omg.example.pro": { plan: "pro", planVersion: 1 } },
|
|
186
|
+
})))).toThrow(/appAppleId is required/);
|
|
187
|
+
expect(() => defineBilling(config(appStore({
|
|
188
|
+
bundleId: "dev.omg.example",
|
|
189
|
+
environment: "Sandbox",
|
|
190
|
+
products: { "dev.omg.example.ghost": { plan: "ghost", planVersion: 1 } },
|
|
191
|
+
})))).toThrow(/maps to unknown plan/);
|
|
192
|
+
});
|
|
158
193
|
});
|
|
159
194
|
|
|
160
195
|
describe("catalog serialization", () => {
|
|
@@ -180,6 +215,29 @@ describe("catalog serialization", () => {
|
|
|
180
215
|
}
|
|
181
216
|
});
|
|
182
217
|
|
|
218
|
+
it("serializes the App Store verification and product mapping contract", () => {
|
|
219
|
+
const b = defineBilling({
|
|
220
|
+
provider: appStore({
|
|
221
|
+
bundleId: "dev.omg.example",
|
|
222
|
+
environment: "Sandbox",
|
|
223
|
+
products: { "dev.omg.example.pro": { plan: "pro", planVersion: 4 } },
|
|
224
|
+
}),
|
|
225
|
+
credit: { unit: "usd", peg: usd(1) },
|
|
226
|
+
features: { access: { kind: "boolean" } },
|
|
227
|
+
plans: {
|
|
228
|
+
free: plan({ price: usd(0), default: true, includes: { access: false } }),
|
|
229
|
+
pro: plan({ price: usd(10), interval: "month", includes: { access: true } }),
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
expect(toCatalog(b).provider).toEqual({
|
|
233
|
+
name: "app-store",
|
|
234
|
+
useProviderMeters: false,
|
|
235
|
+
bundleId: "dev.omg.example",
|
|
236
|
+
environment: "Sandbox",
|
|
237
|
+
products: { "dev.omg.example.pro": { plan: "pro", planVersion: 4 } },
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
183
241
|
it("includes add-on hashes", () => {
|
|
184
242
|
const cat = toCatalog(fixture());
|
|
185
243
|
expect(cat.addOns).toHaveLength(1);
|