@omg-dev/billing 0.4.40 → 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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omg-dev/billing",
3
- "version": "0.4.40",
3
+ "version": "0.4.42",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
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: { name: string; useProviderMeters: false };
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 ProviderDef {
130
- name: ProviderName;
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)) {
@@ -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);