@croco/metrics-billing 0.0.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,81 @@
1
+ # @croco/metrics-billing
2
+
3
+ Billing 도메인 이벤트를 Metrics 계산으로 연결하는 파이프라인 패키지입니다.
4
+
5
+ ## 설치
6
+
7
+ ```bash
8
+ pnpm add @croco/metrics-billing
9
+ ```
10
+
11
+ ## 개요
12
+
13
+ 이 패키지는 `@croco/billing-core`에서 발생하는 도메인 이벤트를 수신하여 `@croco/metrics-core`의 메트릭 계산 엔진으로 전달합니다.
14
+
15
+ ### 지원하는 이벤트
16
+
17
+ | 이벤트 | 설명 | MRR Movement |
18
+ | --------------------------- | -------------- | ------------------------------ |
19
+ | `OrderPaidEvent` | 주문 결제 완료 | `new` |
20
+ | `PlanChangedEvent` | 플랜 변경 | `expansion` 또는 `contraction` |
21
+ | `SubscriptionCanceledEvent` | 구독 취소 | `churned` |
22
+
23
+ ## 사용법
24
+
25
+ ```typescript
26
+ import { BillingEventHandler } from "@croco/metrics-billing";
27
+ import { TimescaleMetricsStore } from "@croco/metrics-core";
28
+ import { Container } from "@croco/framework-context";
29
+
30
+ const metricsRepository = new TimescaleMetricsStore(db);
31
+ const handler = new BillingEventHandler(planRegistry, billingStore, metricsRepository);
32
+
33
+ await eventBus.publish(new OrderPaidEvent("tenant-1", "order-1", 2900, "USD"));
34
+ ```
35
+
36
+ ### DI 컨테이너 등록
37
+
38
+ ```typescript
39
+ import { Container } from "@croco/framework-context";
40
+ import { BillingEventHandler } from "@croco/metrics-billing";
41
+
42
+ Container.register(BillingEventHandler, {
43
+ planRegistry: Container.resolve(PlanRegistry),
44
+ billingStore: Container.resolve(BillingStore),
45
+ metricsRepository: Container.resolve(MetricsRepository),
46
+ });
47
+ ```
48
+
49
+ ## MRR 변동 계산
50
+
51
+ ### OrderPaidEvent
52
+
53
+ - 신규 구독: `new` MRR 기록
54
+ - 연간 플랜: 월별 MRR로 정규화 (amount / 12)
55
+
56
+ ### PlanChangedEvent
57
+
58
+ - 업그레이드: `expansion` MRR 기록 (차액)
59
+ - 다운그레이드: `contraction` MRR 기록 (차액)
60
+ - 동일 금액: `unchanged` (0)
61
+
62
+ ### SubscriptionCanceledEvent
63
+
64
+ - `churned` MRR 기록
65
+ - 취소 시점의 플랜 금액 기준
66
+
67
+ ## 멱등성
68
+
69
+ 이벤트 키를 기반으로 멱등성을 보장합니다:
70
+
71
+ ```
72
+ eventKey = `${eventName}_${timestamp.getTime()}`
73
+ ```
74
+
75
+ 동일한 이벤트 키로 중복 호출되면 TimescaleMetricsStore의 `ON CONFLICT DO NOTHING`이 처리합니다.
76
+
77
+ ## Dependencies
78
+
79
+ - `@croco/billing-core` - Billing 도메인 이벤트
80
+ - `@croco/events-core` - EventHandler 인터페이스
81
+ - `@croco/metrics-core` - Metrics 계산 및 저장
@@ -0,0 +1,27 @@
1
+ import * as _croco_billing_core from '@croco/billing-core';
2
+ import { OrderPaidEvent, PlanChangedEvent, SubscriptionCanceledEvent, PlanRegistry, BillingStore } from '@croco/billing-core';
3
+ import { EventHandler, DomainEvent } from '@croco/events-core';
4
+ import { PlanProvider, MetricsRepository } from '@croco/metrics-core';
5
+
6
+ declare class BillingEventHandler implements EventHandler<OrderPaidEvent | PlanChangedEvent | SubscriptionCanceledEvent>, PlanProvider {
7
+ private readonly planRegistry;
8
+ private readonly billingStore;
9
+ private readonly metricsRepository;
10
+ private readonly calculator;
11
+ constructor(planRegistry: PlanRegistry, billingStore: BillingStore, metricsRepository: MetricsRepository);
12
+ getPlan(planId: string): Promise<{
13
+ id: string;
14
+ amount: number;
15
+ currency: string;
16
+ interval: _croco_billing_core.PlanInterval;
17
+ intervalCount: number;
18
+ } | null>;
19
+ handle(event: DomainEvent): Promise<void>;
20
+ private handleOrderPaid;
21
+ private handlePlanChanged;
22
+ private handleSubscriptionCanceled;
23
+ private createMRRMovement;
24
+ private getEventId;
25
+ }
26
+
27
+ export { BillingEventHandler };
@@ -0,0 +1,27 @@
1
+ import * as _croco_billing_core from '@croco/billing-core';
2
+ import { OrderPaidEvent, PlanChangedEvent, SubscriptionCanceledEvent, PlanRegistry, BillingStore } from '@croco/billing-core';
3
+ import { EventHandler, DomainEvent } from '@croco/events-core';
4
+ import { PlanProvider, MetricsRepository } from '@croco/metrics-core';
5
+
6
+ declare class BillingEventHandler implements EventHandler<OrderPaidEvent | PlanChangedEvent | SubscriptionCanceledEvent>, PlanProvider {
7
+ private readonly planRegistry;
8
+ private readonly billingStore;
9
+ private readonly metricsRepository;
10
+ private readonly calculator;
11
+ constructor(planRegistry: PlanRegistry, billingStore: BillingStore, metricsRepository: MetricsRepository);
12
+ getPlan(planId: string): Promise<{
13
+ id: string;
14
+ amount: number;
15
+ currency: string;
16
+ interval: _croco_billing_core.PlanInterval;
17
+ intervalCount: number;
18
+ } | null>;
19
+ handle(event: DomainEvent): Promise<void>;
20
+ private handleOrderPaid;
21
+ private handlePlanChanged;
22
+ private handleSubscriptionCanceled;
23
+ private createMRRMovement;
24
+ private getEventId;
25
+ }
26
+
27
+ export { BillingEventHandler };
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ "use strict";var u=Object.defineProperty;var m=Object.getOwnPropertyDescriptor;var f=Object.getOwnPropertyNames;var g=Object.prototype.hasOwnProperty;var M=(a,n)=>{for(var e in n)u(a,e,{get:n[e],enumerable:!0})},w=(a,n,e,t)=>{if(n&&typeof n=="object"||typeof n=="function")for(let r of f(n))!g.call(a,r)&&r!==e&&u(a,r,{get:()=>n[r],enumerable:!(t=m(n,r))||t.enumerable});return a};var P=a=>w(u({},"__esModule",{value:!0}),a),p=(a,n,e,t)=>{for(var r=t>1?void 0:t?m(n,e):n,i=a.length-1,o;i>=0;i--)(o=a[i])&&(r=(t?o(n,e,r):o(r))||r);return t&&r&&u(n,e,r),r};var b={};M(b,{BillingEventHandler:()=>s});module.exports=P(b);var c=require("@croco/billing-core"),d=require("@croco/events-core"),h=require("@croco/metrics-core");var s=class{constructor(n,e,t){this.planRegistry=n;this.billingStore=e;this.metricsRepository=t;this.calculator=new h.MrrCalculator}async getPlan(n){let e=await this.planRegistry.getPlan(n);return e===null?null:{id:e.id,amount:e.amount,currency:e.currency,interval:e.interval,intervalCount:e.intervalCount}}async handle(n){if(n instanceof c.OrderPaidEvent){await this.handleOrderPaid(n);return}if(n instanceof c.PlanChangedEvent){await this.handlePlanChanged(n);return}n instanceof c.SubscriptionCanceledEvent&&await this.handleSubscriptionCanceled(n)}async handleOrderPaid(n){let e=await this.billingStore.findAccountByTenantId(n.tenantId);if(e===null)return;let t=await this.billingStore.findSubscription(e.id);if(t===null)return;let r=await this.getPlan(t.planId);if(r===null)return;let o={amount:this.calculator.normalizeMRR(r.amount,r.interval,r.intervalCount),currency:r.currency},l=this.createMRRMovement(o,"new");await this.metricsRepository.recordMRRMovement(n.tenantId,l,n.timestamp,this.getEventId(n))}async handlePlanChanged(n){if(await this.billingStore.findAccountByTenantId(n.tenantId)===null||await this.billingStore.findSubscriptionByExternalId(n.externalSubscriptionId)===null)return;let r=await this.getPlan(n.previousPlanId),i=await this.getPlan(n.newPlanId);if(r===null||i===null)return;let o=this.calculator.normalizeMRR(r.amount,r.interval,r.intervalCount),l=this.calculator.normalizeMRR(i.amount,i.interval,i.intervalCount),v=this.calculator.classifyMRRMovement(!0,!1,o,l),y={amount:Math.abs(l-o),currency:i.currency},R=this.createMRRMovement(y,v);await this.metricsRepository.recordMRRMovement(n.tenantId,R,n.timestamp,this.getEventId(n))}async handleSubscriptionCanceled(n){let e=await this.billingStore.findSubscriptionByExternalId(n.externalSubscriptionId);if(e===null)return;let t=await this.getPlan(e.planId);if(t===null)return;let i={amount:this.calculator.normalizeMRR(t.amount,t.interval,t.intervalCount),currency:t.currency},o=this.createMRRMovement(i,"churned");await this.metricsRepository.recordMRRMovement(n.tenantId,o,n.timestamp,this.getEventId(n))}createMRRMovement(n,e){let t={amount:0,currency:n.currency};switch(e){case"new":return{new:n,expansion:t,contraction:t,churned:t,reactivation:t,net:n};case"expansion":return{new:t,expansion:n,contraction:t,churned:t,reactivation:t,net:n};case"contraction":return{new:t,expansion:t,contraction:n,churned:t,reactivation:t,net:{amount:-n.amount,currency:n.currency}};case"churned":return{new:t,expansion:t,contraction:t,churned:n,reactivation:t,net:{amount:-n.amount,currency:n.currency}};case"reactivation":return{new:t,expansion:t,contraction:t,churned:t,reactivation:n,net:n};case"unchanged":return{new:t,expansion:t,contraction:t,churned:t,reactivation:t,net:t}}}getEventId(n){return`${n.eventName}_${n.timestamp.getTime()}`}};s=p([(0,d.RegisterEventHandler)(c.OrderPaidEvent),(0,d.RegisterEventHandler)(c.PlanChangedEvent),(0,d.RegisterEventHandler)(c.SubscriptionCanceledEvent)],s);0&&(module.exports={BillingEventHandler});
package/dist/index.mjs ADDED
@@ -0,0 +1 @@
1
+ var R=Object.defineProperty;var f=Object.getOwnPropertyDescriptor;var u=(s,n,e,t)=>{for(var r=t>1?void 0:t?f(n,e):n,i=s.length-1,a;i>=0;i--)(a=s[i])&&(r=(t?a(n,e,r):a(r))||r);return t&&r&&R(n,e,r),r};import{OrderPaidEvent as d,PlanChangedEvent as m,SubscriptionCanceledEvent as p}from"@croco/billing-core";import{RegisterEventHandler as l}from"@croco/events-core";import{MrrCalculator as g}from"@croco/metrics-core";var o=class{constructor(n,e,t){this.planRegistry=n;this.billingStore=e;this.metricsRepository=t;this.calculator=new g}async getPlan(n){let e=await this.planRegistry.getPlan(n);return e===null?null:{id:e.id,amount:e.amount,currency:e.currency,interval:e.interval,intervalCount:e.intervalCount}}async handle(n){if(n instanceof d){await this.handleOrderPaid(n);return}if(n instanceof m){await this.handlePlanChanged(n);return}n instanceof p&&await this.handleSubscriptionCanceled(n)}async handleOrderPaid(n){let e=await this.billingStore.findAccountByTenantId(n.tenantId);if(e===null)return;let t=await this.billingStore.findSubscription(e.id);if(t===null)return;let r=await this.getPlan(t.planId);if(r===null)return;let a={amount:this.calculator.normalizeMRR(r.amount,r.interval,r.intervalCount),currency:r.currency},c=this.createMRRMovement(a,"new");await this.metricsRepository.recordMRRMovement(n.tenantId,c,n.timestamp,this.getEventId(n))}async handlePlanChanged(n){if(await this.billingStore.findAccountByTenantId(n.tenantId)===null||await this.billingStore.findSubscriptionByExternalId(n.externalSubscriptionId)===null)return;let r=await this.getPlan(n.previousPlanId),i=await this.getPlan(n.newPlanId);if(r===null||i===null)return;let a=this.calculator.normalizeMRR(r.amount,r.interval,r.intervalCount),c=this.calculator.normalizeMRR(i.amount,i.interval,i.intervalCount),h=this.calculator.classifyMRRMovement(!0,!1,a,c),v={amount:Math.abs(c-a),currency:i.currency},y=this.createMRRMovement(v,h);await this.metricsRepository.recordMRRMovement(n.tenantId,y,n.timestamp,this.getEventId(n))}async handleSubscriptionCanceled(n){let e=await this.billingStore.findSubscriptionByExternalId(n.externalSubscriptionId);if(e===null)return;let t=await this.getPlan(e.planId);if(t===null)return;let i={amount:this.calculator.normalizeMRR(t.amount,t.interval,t.intervalCount),currency:t.currency},a=this.createMRRMovement(i,"churned");await this.metricsRepository.recordMRRMovement(n.tenantId,a,n.timestamp,this.getEventId(n))}createMRRMovement(n,e){let t={amount:0,currency:n.currency};switch(e){case"new":return{new:n,expansion:t,contraction:t,churned:t,reactivation:t,net:n};case"expansion":return{new:t,expansion:n,contraction:t,churned:t,reactivation:t,net:n};case"contraction":return{new:t,expansion:t,contraction:n,churned:t,reactivation:t,net:{amount:-n.amount,currency:n.currency}};case"churned":return{new:t,expansion:t,contraction:t,churned:n,reactivation:t,net:{amount:-n.amount,currency:n.currency}};case"reactivation":return{new:t,expansion:t,contraction:t,churned:t,reactivation:n,net:n};case"unchanged":return{new:t,expansion:t,contraction:t,churned:t,reactivation:t,net:t}}}getEventId(n){return`${n.eventName}_${n.timestamp.getTime()}`}};o=u([l(d),l(m),l(p)],o);export{o as BillingEventHandler};
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@croco/metrics-billing",
3
+ "version": "0.0.1",
4
+ "files": [
5
+ "dist"
6
+ ],
7
+ "type": "commonjs",
8
+ "main": "./dist/index.js",
9
+ "module": "./dist/index.mjs",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "import": "./dist/index.js",
14
+ "require": "./dist/index.cjs",
15
+ "types": "./dist/index.d.ts"
16
+ }
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "dependencies": {
22
+ "@croco/billing-core": "0.0.1",
23
+ "@croco/events-core": "0.0.1",
24
+ "@croco/metrics-core": "0.0.1"
25
+ },
26
+ "devDependencies": {
27
+ "reflect-metadata": "^0.2.2",
28
+ "tsup": "^8.4.0",
29
+ "typescript": "^5.7.2",
30
+ "vitest": "4.0.16"
31
+ },
32
+ "scripts": {
33
+ "build": "tsup src/index.ts --format esm,cjs --minify --clean --dts",
34
+ "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
35
+ "test": "vitest run",
36
+ "typecheck": "tsc --noEmit",
37
+ "lint": "oxlint ."
38
+ }
39
+ }