@optizio/merchant-identity 0.2.0 → 0.5.0

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/index.d.ts CHANGED
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * Consumed by:
5
5
  * - the `merchant-identity` service Worker, which implements `IdentityClient`
6
- * against the shared D1, and
6
+ * and `RevenueClient` against the shared D1, and
7
7
  * - portfolio apps (DiscountKit, CodeBulk, Stackable), which call the service
8
- * over an RPC service binding through the `identity()` seam below.
8
+ * over an RPC service binding through the `identity()` / `revenue()` seams
9
+ * below.
9
10
  *
10
11
  * This package holds only types + pure helpers, so it has no Cloudflare or
11
12
  * runtime dependencies and is safe to import anywhere.
@@ -177,3 +178,297 @@ export declare const NOOP_IDENTITY: IdentityClient;
177
178
  * any call sites.
178
179
  */
179
180
  export declare function identity(binding: IdentityClient | null | undefined, disabled?: boolean | string): IdentityClient;
181
+ /**
182
+ * Every amount below is an integer in the currency's minor units (cents for
183
+ * USD), never a float, and is quoted in the service's report currency
184
+ * (`REVENUE_REPORT_CURRENCY`, USD by default). Amounts the Partner API reported
185
+ * in another currency are stored raw but excluded from these aggregates and
186
+ * counted in the sync status, so a non-zero `skippedCurrencyRows` means the
187
+ * numbers below are understating the portfolio.
188
+ */
189
+ export declare const MINOR_UNIT_SCALE = 2;
190
+ /**
191
+ * Render minor units as a plain decimal string (`123456` -> `"1234.56"`).
192
+ * String math throughout: passing these through a float would round-trip
193
+ * large amounts inexactly, and the point of minor units is exactness.
194
+ */
195
+ export declare function minorUnitsToDecimal(minor: number, scale?: number): string;
196
+ /** `app_gid` sentinel for the portfolio-wide rollup row. */
197
+ export declare const PORTFOLIO_APP_GID = "*";
198
+ export type ChargeStatus = 'active' | 'frozen' | 'canceled' | 'declined' | 'expired';
199
+ /** How a charge's amount is normalised into MRR. */
200
+ export type BillingInterval = 'MONTHLY' | 'ANNUAL';
201
+ export type MovementType = 'new' | 'expansion' | 'contraction' | 'churn' | 'reactivation' | 'frozen' | 'unfrozen';
202
+ export declare const MOVEMENT_TYPES: readonly MovementType[];
203
+ export type TrialOutcome = 'trialing' | 'converted' | 'canceled' | 'expired' | 'declined' | 'uninstalled';
204
+ /** The three Partner API feeds the sync engine polls. */
205
+ export type SyncFeed = 'transactions' | 'app_events' | 'account_events' | 'subscriptions';
206
+ export type SyncMode = 'backfill' | 'incremental';
207
+ export interface RevenueCharge {
208
+ chargeGid: string;
209
+ appGid: string;
210
+ /** Shopify client_id of the app, i.e. `merchant_apps.app_id`. */
211
+ apiKey: string | null;
212
+ appName: string | null;
213
+ shopKey: string;
214
+ shopDomain: string | null;
215
+ status: ChargeStatus;
216
+ amountMinor: number;
217
+ currencyCode: string | null;
218
+ interval: BillingInterval;
219
+ /** Monthly-normalised list price: what the plan costs before any discount. */
220
+ grossMrrMinor: number;
221
+ /** Monthly-normalised discount taken off `grossMrrMinor`. Never negative. */
222
+ discountMinor: number;
223
+ /** When the discount runs out; null for one that never does, or no discount. */
224
+ discountExpiresAt: string | null;
225
+ /**
226
+ * Monthly-normalised **net of discount**: what the shop is actually billed.
227
+ * `grossMrrMinor - discountMinor`, and 0 alongside a 0 gross while the charge is
228
+ * trialing. A frozen charge keeps its MRR — Shopify resumes billing at the same
229
+ * price when the store settles up.
230
+ */
231
+ mrrMinor: number;
232
+ /** The shop has asked to cancel at the end of the current cycle. */
233
+ cancelAtEndOfCycle: boolean;
234
+ /**
235
+ * When this charge's terms were last read from the live subscription. Null means
236
+ * they are still only what the event history implied, so a discount on this
237
+ * charge would not be reflected yet.
238
+ */
239
+ reconciledAt: string | null;
240
+ planName: string | null;
241
+ planHandle: string | null;
242
+ billingOn: string | null;
243
+ isTrial: boolean;
244
+ firstActivatedAt: string | null;
245
+ lastEventAt: string | null;
246
+ terminatedAt: string | null;
247
+ firstPaidAt: string | null;
248
+ lastPaidAt: string | null;
249
+ paidSaleCount: number;
250
+ }
251
+ /** Everything the support viewer needs about one store's billing. */
252
+ export interface MerchantRevenueSummary {
253
+ shopDomain: string;
254
+ shopKey: string;
255
+ /** Report currency the minor-unit amounts are quoted in. */
256
+ currencyCode: string;
257
+ /** Sum of `mrrMinor` (net of discounts) across active, non-trialing charges. */
258
+ mrrMinor: number;
259
+ /** The same sum before discounts, i.e. at list price. */
260
+ grossMrrMinor: number;
261
+ /** `grossMrrMinor - mrrMinor`: what discounts take off this store's bill. */
262
+ discountMinor: number;
263
+ charges: RevenueCharge[];
264
+ lifetimeGrossMinor: number;
265
+ lifetimeNetMinor: number;
266
+ firstPaidAt: string | null;
267
+ lastPaidAt: string | null;
268
+ activeTrials: number;
269
+ /** Null when the store has no derived rows yet (never billed, or unsynced). */
270
+ computedAt: string | null;
271
+ }
272
+ /** One UTC day of the derived series: stock metrics at end of day, flows for the day. */
273
+ export interface MrrSeriesPoint {
274
+ date: string;
275
+ /** Net of discounts: `grossMrrMinor - discountMinor`. */
276
+ mrrMinor: number;
277
+ /** List price of everything billing that day, before discounts. */
278
+ grossMrrMinor: number;
279
+ discountMinor: number;
280
+ /** The part of `discountMinor` that has an end date or cycles remaining. */
281
+ expiringDiscountMinor: number;
282
+ subscriptionMrrMinor: number;
283
+ usageMrrMinor: number;
284
+ activeSubscriptions: number;
285
+ activeTrials: number;
286
+ payingMerchants: number;
287
+ activeInstalls: number;
288
+ newMinor: number;
289
+ expansionMinor: number;
290
+ contractionMinor: number;
291
+ churnMinor: number;
292
+ reactivationMinor: number;
293
+ installs: number;
294
+ uninstalls: number;
295
+ trialsStarted: number;
296
+ trialsConverted: number;
297
+ trialsLost: number;
298
+ grossMinor: number;
299
+ netMinor: number;
300
+ }
301
+ export interface MovementBreakdown {
302
+ from: string;
303
+ to: string;
304
+ appGid: string;
305
+ openingMrrMinor: number;
306
+ closingMrrMinor: number;
307
+ newMinor: number;
308
+ expansionMinor: number;
309
+ contractionMinor: number;
310
+ churnMinor: number;
311
+ reactivationMinor: number;
312
+ /** Signed sum of the components; equals closing - opening subscription MRR. */
313
+ netMinor: number;
314
+ counts: Record<MovementType, number>;
315
+ }
316
+ export interface TrialFunnelCohort {
317
+ /** `YYYY-MM` of the trial start. */
318
+ cohort: string;
319
+ trialsStarted: number;
320
+ converted: number;
321
+ canceled: number;
322
+ expired: number;
323
+ declined: number;
324
+ uninstalled: number;
325
+ stillTrialing: number;
326
+ /** `converted / trialsStarted`, 0 when the cohort is empty. */
327
+ conversionRate: number;
328
+ convertedMrrMinor: number;
329
+ }
330
+ export interface SyncFeedStatus {
331
+ feed: SyncFeed;
332
+ scope: string;
333
+ mode: SyncMode;
334
+ windowStart: string | null;
335
+ windowEnd: string | null;
336
+ watermarkAt: string | null;
337
+ /**
338
+ * The feed has read everything there is. For the `subscriptions` recheck, which has
339
+ * no history to walk, it means nothing was stale when it last looked — so it dips
340
+ * back to false while a backlog drains, and the coordinator reads it to know there
341
+ * is a pass to come back for.
342
+ */
343
+ backfillComplete: boolean;
344
+ pagesTotal: number;
345
+ rowsTotal: number;
346
+ lastRunAt: string | null;
347
+ lastSuccessAt: string | null;
348
+ lastError: string | null;
349
+ lastErrorAt: string | null;
350
+ consecutiveFailures: number;
351
+ }
352
+ export interface ReplayAppStatus {
353
+ appGid: string;
354
+ appName: string | null;
355
+ apiKey: string | null;
356
+ dirty: boolean;
357
+ lastReplayAt: string | null;
358
+ lastReplayMs: number | null;
359
+ eventsReplayed: number | null;
360
+ throughAt: string | null;
361
+ skippedCurrencyRows: number;
362
+ lastError: string | null;
363
+ }
364
+ /** How current the live-subscription reconciliation is. */
365
+ export interface SubscriptionReconciliationStatus {
366
+ /** (app, shop) pairs whose live subscription has been read at least once. */
367
+ checkedPairs: number;
368
+ /**
369
+ * Pairs the recheck owes a read: past the recheck interval, or never read at all.
370
+ *
371
+ * Counted over the pairs it will actually revisit — an active or frozen charge, or
372
+ * recent subscription activity with the app still installed — rather than over
373
+ * stored rows, so it is not bounded by `checkedPairs`. A pair that leaves that set
374
+ * keeps the timestamp of its last read and can never be refreshed, so counting it
375
+ * would hold this above 0 for good. Reaches 0 when the pass is caught up, which
376
+ * makes it the figure to alert on.
377
+ */
378
+ stalePairs: number;
379
+ /** Pairs the API reports as having no live subscription. */
380
+ absentSubscriptions: number;
381
+ /**
382
+ * Pairs we could not ask about, because the feeds have never given us a shop GID
383
+ * and `activeSubscription` takes one. Held apart from `absentSubscriptions` so a
384
+ * shop we cannot identify is never read as a cancellation; the GID is recovered
385
+ * from any feed that carries it, so this should be 0 and is worth an alert when
386
+ * it is not.
387
+ */
388
+ unresolvedPairs: number;
389
+ /**
390
+ * Live subscriptions whose charge we have never seen in the event history. Each
391
+ * one is revenue we cannot price, because the API exposes the discount but not
392
+ * the plan amount, so this should be 0 and is worth an alert when it is not.
393
+ */
394
+ unpricedSubscriptions: number;
395
+ /**
396
+ * How current the recheck is: the oldest read among the pairs it still revisits, on
397
+ * the same reasoning as `stalePairs`. Null before the first read.
398
+ */
399
+ oldestCheckedAt: string | null;
400
+ }
401
+ export interface RevenueSyncStatus {
402
+ currencyCode: string;
403
+ /** True when the service has no Partner token or is explicitly disabled. */
404
+ disabled: boolean;
405
+ feeds: SyncFeedStatus[];
406
+ apps: ReplayAppStatus[];
407
+ dirtyApps: number;
408
+ reconciliation: SubscriptionReconciliationStatus;
409
+ /**
410
+ * How current the raw layer is: the *oldest* watermark across the ingesting feeds,
411
+ * since the numbers are only complete up to whichever feed is furthest behind. The
412
+ * subscription recheck is not an ingesting feed and does not count towards it — see
413
+ * `reconciliation.oldestCheckedAt` for that.
414
+ */
415
+ watermarkAt: string | null;
416
+ }
417
+ /**
418
+ * Scope for the aggregate reads. `appGid` and `apiKey` both select a single app
419
+ * (`apiKey` being the Shopify client_id an app already knows about itself);
420
+ * passing neither reads the portfolio rollup.
421
+ */
422
+ export interface RevenueScope {
423
+ appGid?: string;
424
+ apiKey?: string;
425
+ }
426
+ export interface MrrSeriesQuery extends RevenueScope {
427
+ /** Inclusive `YYYY-MM-DD`. Defaults to 90 days before `to`. */
428
+ from?: string;
429
+ /** Inclusive `YYYY-MM-DD`. Defaults to the latest computed day. */
430
+ to?: string;
431
+ /** Hard cap on returned points (most recent kept). Defaults to 400. */
432
+ limit?: number;
433
+ }
434
+ export interface MovementQuery extends RevenueScope {
435
+ from: string;
436
+ to: string;
437
+ }
438
+ export interface TrialFunnelQuery extends RevenueScope {
439
+ /** Inclusive `YYYY-MM` cohort bounds. Default: the last 12 months. */
440
+ fromMonth?: string;
441
+ toMonth?: string;
442
+ }
443
+ /**
444
+ * The read surface over derived Partner revenue, exposed by the same Worker as
445
+ * `IdentityClient` (entrypoint `MerchantRevenue`) because both own the same D1.
446
+ * Reads never touch the Partner API: they read tables the sync + replay have
447
+ * already built, so they are cheap and safe to call per request.
448
+ *
449
+ * `requestSync` / `requestReplay` are internal operator hooks, not app-facing;
450
+ * they only nudge the sync coordinator and return immediately.
451
+ */
452
+ export interface RevenueClient {
453
+ getMerchantRevenue(shopDomain: string): Promise<MerchantRevenueSummary>;
454
+ getMrrSeries(query?: MrrSeriesQuery): Promise<MrrSeriesPoint[]>;
455
+ getMovementBreakdown(query: MovementQuery): Promise<MovementBreakdown>;
456
+ getTrialFunnel(query?: TrialFunnelQuery): Promise<TrialFunnelCohort[]>;
457
+ getSyncStatus(): Promise<RevenueSyncStatus>;
458
+ requestSync(reason?: string): Promise<void>;
459
+ requestReplay(appGid?: string): Promise<void>;
460
+ }
461
+ /** Empty summary for a store with no billing history (or no service). */
462
+ export declare function emptyMerchantRevenue(shopDomain: string, currencyCode?: string): MerchantRevenueSummary;
463
+ /**
464
+ * No-op client used when the service is unavailable (local dev) or explicitly
465
+ * disabled via `REVENUE_DISABLED`. Reads return empty rather than throwing so a
466
+ * dashboard renders zeroes instead of failing.
467
+ */
468
+ export declare const NOOP_REVENUE: RevenueClient;
469
+ /**
470
+ * Single seam for the revenue binding, mirroring `identity()`: returns the real
471
+ * binding when present and enabled, otherwise `NOOP_REVENUE`, so an app renders
472
+ * without the service bound.
473
+ */
474
+ export declare function revenue(binding: RevenueClient | null | undefined, disabled?: boolean | string): RevenueClient;
package/dist/index.js CHANGED
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * Consumed by:
5
5
  * - the `merchant-identity` service Worker, which implements `IdentityClient`
6
- * against the shared D1, and
6
+ * and `RevenueClient` against the shared D1, and
7
7
  * - portfolio apps (DiscountKit, CodeBulk, Stackable), which call the service
8
- * over an RPC service binding through the `identity()` seam below.
8
+ * over an RPC service binding through the `identity()` / `revenue()` seams
9
+ * below.
9
10
  *
10
11
  * This package holds only types + pure helpers, so it has no Cloudflare or
11
12
  * runtime dependencies and is safe to import anywhere.
@@ -78,3 +79,130 @@ export function identity(binding, disabled) {
78
79
  }
79
80
  return binding;
80
81
  }
82
+ /* -------------------------------------------------------------------------- */
83
+ /* Revenue: money representation */
84
+ /* -------------------------------------------------------------------------- */
85
+ /**
86
+ * Every amount below is an integer in the currency's minor units (cents for
87
+ * USD), never a float, and is quoted in the service's report currency
88
+ * (`REVENUE_REPORT_CURRENCY`, USD by default). Amounts the Partner API reported
89
+ * in another currency are stored raw but excluded from these aggregates and
90
+ * counted in the sync status, so a non-zero `skippedCurrencyRows` means the
91
+ * numbers below are understating the portfolio.
92
+ */
93
+ export const MINOR_UNIT_SCALE = 2;
94
+ /**
95
+ * Render minor units as a plain decimal string (`123456` -> `"1234.56"`).
96
+ * String math throughout: passing these through a float would round-trip
97
+ * large amounts inexactly, and the point of minor units is exactness.
98
+ */
99
+ export function minorUnitsToDecimal(minor, scale = MINOR_UNIT_SCALE) {
100
+ const negative = minor < 0;
101
+ const digits = Math.abs(Math.trunc(minor)).toString().padStart(scale + 1, '0');
102
+ const whole = digits.slice(0, digits.length - scale);
103
+ const fraction = scale > 0 ? `.${digits.slice(digits.length - scale)}` : '';
104
+ return `${negative ? '-' : ''}${whole}${fraction}`;
105
+ }
106
+ /* -------------------------------------------------------------------------- */
107
+ /* Revenue: enums + sentinels */
108
+ /* -------------------------------------------------------------------------- */
109
+ /** `app_gid` sentinel for the portfolio-wide rollup row. */
110
+ export const PORTFOLIO_APP_GID = '*';
111
+ export const MOVEMENT_TYPES = [
112
+ 'new',
113
+ 'expansion',
114
+ 'contraction',
115
+ 'churn',
116
+ 'reactivation',
117
+ 'frozen',
118
+ 'unfrozen',
119
+ ];
120
+ /** Empty summary for a store with no billing history (or no service). */
121
+ export function emptyMerchantRevenue(shopDomain, currencyCode = 'USD') {
122
+ return {
123
+ shopDomain,
124
+ shopKey: shopDomain,
125
+ currencyCode,
126
+ mrrMinor: 0,
127
+ grossMrrMinor: 0,
128
+ discountMinor: 0,
129
+ charges: [],
130
+ lifetimeGrossMinor: 0,
131
+ lifetimeNetMinor: 0,
132
+ firstPaidAt: null,
133
+ lastPaidAt: null,
134
+ activeTrials: 0,
135
+ computedAt: null,
136
+ };
137
+ }
138
+ /**
139
+ * No-op client used when the service is unavailable (local dev) or explicitly
140
+ * disabled via `REVENUE_DISABLED`. Reads return empty rather than throwing so a
141
+ * dashboard renders zeroes instead of failing.
142
+ */
143
+ export const NOOP_REVENUE = {
144
+ async getMerchantRevenue(shopDomain) {
145
+ return emptyMerchantRevenue(shopDomain);
146
+ },
147
+ async getMrrSeries() {
148
+ return [];
149
+ },
150
+ async getMovementBreakdown(query) {
151
+ return {
152
+ from: query.from,
153
+ to: query.to,
154
+ appGid: query.appGid ?? PORTFOLIO_APP_GID,
155
+ openingMrrMinor: 0,
156
+ closingMrrMinor: 0,
157
+ newMinor: 0,
158
+ expansionMinor: 0,
159
+ contractionMinor: 0,
160
+ churnMinor: 0,
161
+ reactivationMinor: 0,
162
+ netMinor: 0,
163
+ counts: {
164
+ new: 0,
165
+ expansion: 0,
166
+ contraction: 0,
167
+ churn: 0,
168
+ reactivation: 0,
169
+ frozen: 0,
170
+ unfrozen: 0,
171
+ },
172
+ };
173
+ },
174
+ async getTrialFunnel() {
175
+ return [];
176
+ },
177
+ async getSyncStatus() {
178
+ return {
179
+ currencyCode: 'USD',
180
+ disabled: true,
181
+ feeds: [],
182
+ apps: [],
183
+ dirtyApps: 0,
184
+ reconciliation: {
185
+ checkedPairs: 0,
186
+ stalePairs: 0,
187
+ absentSubscriptions: 0,
188
+ unresolvedPairs: 0,
189
+ unpricedSubscriptions: 0,
190
+ oldestCheckedAt: null,
191
+ },
192
+ watermarkAt: null,
193
+ };
194
+ },
195
+ async requestSync() { },
196
+ async requestReplay() { },
197
+ };
198
+ /**
199
+ * Single seam for the revenue binding, mirroring `identity()`: returns the real
200
+ * binding when present and enabled, otherwise `NOOP_REVENUE`, so an app renders
201
+ * without the service bound.
202
+ */
203
+ export function revenue(binding, disabled) {
204
+ if (!binding || disabled === true || disabled === 'true') {
205
+ return NOOP_REVENUE;
206
+ }
207
+ return binding;
208
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@optizio/merchant-identity",
3
- "version": "0.2.0",
4
- "description": "Shared merchant/user identity types + RPC client contract for the merchant-hub portfolio.",
3
+ "version": "0.5.0",
4
+ "description": "Shared merchant/user identity + revenue types and RPC client contracts for the merchant-hub portfolio.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/optizio/merchant-hub.git",