@happyvertical/smrt-affiliates 0.39.1 → 0.39.3

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/AGENTS.md CHANGED
@@ -1,62 +1,51 @@
1
1
  # @happyvertical/smrt-affiliates
2
2
 
3
- Partner revenue sharing with multi-type partners, multi-tier commissions, and payout processing.
4
-
5
- ## Models
6
-
7
- - **Partner**: `partnerTypes` JSON array (publisher/salesperson/referrer — multi-role). `parentPartnerId` for site-attached salespeople. `referredById` for referral attribution. `commissionRate`, `parentCommissionShare`.
8
- - **Commission**: 4 types per ad event — Display (publisher), Referral (referrer), Sales (salesperson), Parent (parent publisher's share). **Immutable** — no update/delete API.
9
- - **Payout**: batch aggregation. Status: `PENDING → APPROVED → PROCESSING → COMPLETED` (or FAILED).
10
-
11
- ## Currency
12
-
13
- **All monetary fields are integer cents.** Helpers: `getTotalInDollars()`, `getAmountInDollars()`. `Commission.calculateAmount(grossRevenue, rate)` uses `Math.round()`.
14
-
15
- ## Parent Commission Share
16
-
17
- ```
18
- Salesperson.parentCommissionShare = 0.20 (20% to parent publisher)
19
- Effective sales rate = salesRate × (1 - parentCommissionShare)
20
- ```
21
-
22
- ## Cross-Package References (plain strings)
23
-
24
- `profileId` → smrt-profiles, `propertyId` → smrt-properties, `eventId` → smrt-ads, `invoiceId` → smrt-commerce
25
-
26
- ## Tenancy
27
-
28
- Per `docs/content/standards.md §7`, tenant-aware models normally apply
29
- `@TenantScoped({ mode: 'optional' })` from `@happyvertical/smrt-tenancy`. The
30
- three models in this package deviate intentionally; each `@smrt(...)` block
31
- carries an inline comment pointing back to this section.
32
-
33
- `Partner`, `Commission`, and `Payout` are deliberately **NOT** tenant-scoped.
34
- The affiliate network is a cross-tenant graph by design:
35
-
36
- - A single `Partner` (e.g. a publisher operating multiple sites across
37
- different tenants) needs a stable identity for revenue aggregation,
38
- payout thresholds, and tax reporting. Slicing partner identity per
39
- tenant would either duplicate the row or hide payouts owed across
40
- tenants.
41
- - `Commission` rows attribute revenue to a partner across whichever tenant
42
- generated the ad event; the cross-tenant attribution is the point of the
43
- network.
44
- - `Payout` aggregates commissions for a partner regardless of which tenant
45
- the underlying revenue came from. A tenant-scoped query would produce
46
- systematically incorrect totals.
47
-
48
- This is the same reasoning that keeps `TenantKey` in `packages/secrets`
49
- out of the tenancy interceptor: rows that must be queried across tenants
50
- to fulfil their purpose should not be silently filtered.
51
-
52
- Operators that need tenant-attributed reporting should aggregate by
53
- joining `Commission` rows back to `eventId` (smrt-ads) and the originating
54
- ad's tenant — not by adding `@TenantScoped` here.
3
+ **DEPRECATED compatibility shim** over the `@happyvertical/smrt-sales`
4
+ commissions core. This package declares **no models and owns no persistence**
5
+ — every export is a re-export (or a legacy-shaped alias) of the sales
6
+ package's neutral commissions module, kept so existing imports compile while
7
+ consumers migrate. It will be removed in a future major release.
8
+
9
+ - Migration path (tables, columns, statuses, SQL sketches): `MIGRATION.md`
10
+ in this package.
11
+ - The real API: `packages/sales/AGENTS.md` (`commissions` module).
12
+
13
+ ## What the shim exports
14
+
15
+ | Shim export | Actual thing |
16
+ |---|---|
17
+ | `Partner` / `PartnerCollection` | `Earner` / `EarnerCollection` from `@happyvertical/smrt-sales` (same class references) |
18
+ | `Commission` / `CommissionCollection` | `Commission` / `CommissionCollection` from `@happyvertical/smrt-sales` (new shape) |
19
+ | `Payout` / `PayoutCollection` | `CommissionPayout` / `CommissionPayoutCollection` from `@happyvertical/smrt-sales` |
20
+ | `PartnerOptions` / `CommissionOptions` / `PayoutOptions` | `EarnerOptions` / `CommissionOptions` / `CommissionPayoutOptions` type aliases |
21
+ | `PartnerStatus`, `PartnerType`, `CommissionType`, `CommissionStatus`, `PayoutStatus`, `PayoutMethod` | frozen `as const` objects preserving the exact legacy enum members/values, each with a matching derived type |
22
+
23
+ The shim deliberately does **not** re-export the rest of the new surface
24
+ (no `Earner`-named exports, no plans/events/adjustments/services) — new code
25
+ imports `@happyvertical/smrt-sales` directly.
26
+
27
+ ## Name/value mapping notes
28
+
29
+ - `PartnerStatus` values align exactly with the `EarnerStatus` union.
30
+ - `PayoutStatus` values align exactly with the `CommissionPayoutStatus`
31
+ union; `PayoutMethod` values align with the new `PayoutMethod` union
32
+ (which adds `'other'`).
33
+ - `CommissionStatus.INCLUDED` (`'included'`) has **no direct equivalent**;
34
+ the nearest new state is `'payable'` with `payoutId` set. `PENDING`/`PAID`
35
+ align.
36
+ - `PartnerType` has no equivalent — roles are first-class models now:
37
+ `Referrer` (`@happyvertical/smrt-sales/referrals`) and
38
+ `SalesRepresentative` (`@happyvertical/smrt-sales/crm`), each holding an
39
+ `earnerId`.
40
+ - `CommissionType` is superseded by `CommissionPlan` component keys.
55
41
 
56
42
  ## Gotchas
57
43
 
58
- - **No tenancy** (intentional): cross-tenant network visibility for affiliate tracking — see Tenancy section above for rationale
59
- - **partnerTypes is JSON string**: must parse with `getPartnerTypes()` helper
60
- - **Commission rate copied at event time**: immutable record, not a live reference to Partner.commissionRate
61
- - **Payout amounts in cents**: divide by 100 for display
62
- - **No ledger integration**: Payout → Invoice mapping is external
44
+ - **No manifest, no tables**: the package emits no SMRT objects; there is no
45
+ `./manifest` export anymore. Data lives in the sales tables (`earners`,
46
+ `commissions`, `commission_payouts`).
47
+ - **Tenancy stance changed**: the legacy models were intentionally NOT
48
+ tenant-scoped; the sales models are `@TenantScoped({ mode: 'optional' })`.
49
+ Legacy rows migrate with `tenant_id = NULL` (global) — see `MIGRATION.md`.
50
+ - **Do not add models here**: new commission features belong in
51
+ `packages/sales/src/commissions/`.
package/MIGRATION.md ADDED
@@ -0,0 +1,320 @@
1
+ # Migrating from `@happyvertical/smrt-affiliates` to `@happyvertical/smrt-sales`
2
+
3
+ `@happyvertical/smrt-affiliates` is now a **deprecated compatibility shim**.
4
+ It declares no models and owns no persistence: every class it exports is the
5
+ same runtime class as the corresponding `@happyvertical/smrt-sales`
6
+ commissions-core export, re-exported under the legacy name. The legacy
7
+ `partners` / `commissions` / `payouts` schema is no longer emitted by this
8
+ package — data lives in the sales tables.
9
+
10
+ ## No flag day
11
+
12
+ Existing imports keep compiling through this shim: `Partner` **is**
13
+ `Earner`, `Payout` **is** `CommissionPayout`, and the legacy enum-style
14
+ const objects (`PartnerStatus`, `PayoutStatus`, …) preserve their exact
15
+ member names and string values. Move field-level code to the new API at your
16
+ own pace; the shim will be retired in a future major release. New code should
17
+ import from `@happyvertical/smrt-sales` directly.
18
+
19
+ ## API surface mapping
20
+
21
+ | Legacy export (`smrt-affiliates`) | New export (`@happyvertical/smrt-sales`) | Notes |
22
+ |---|---|---|
23
+ | `Partner` | `Earner` | Same class via the shim. Financial identity only — roles moved out (see below). |
24
+ | `PartnerCollection` | `EarnerCollection` | Same class via the shim. |
25
+ | `Commission` | `Commission` | Same name, **new shape** (see column mapping). |
26
+ | `CommissionCollection` | `CommissionCollection` | Same class via the shim. |
27
+ | `Payout` | `CommissionPayout` | Same class via the shim. Renamed to avoid the global `payouts` table-name collision with commerce. |
28
+ | `PayoutCollection` | `CommissionPayoutCollection` | Same class via the shim. |
29
+ | `PartnerOptions` | `EarnerOptions` | Type alias via the shim. |
30
+ | `CommissionOptions` | `CommissionOptions` | Type alias via the shim (new shape). |
31
+ | `PayoutOptions` | `CommissionPayoutOptions` | Type alias via the shim. |
32
+ | `PartnerStatus` enum | `EarnerStatus` union (`EARNER_STATUSES`) | Values align exactly. |
33
+ | `PartnerType` enum | — (roles are models now) | `Referrer` (`@happyvertical/smrt-sales/referrals`), `SalesRepresentative` (`@happyvertical/smrt-sales/crm`). |
34
+ | `CommissionType` enum | — (plan component keys) | See the worked `CommissionPlan` below. |
35
+ | `CommissionStatus` enum | `CommissionStatus` union (`COMMISSION_STATUSES`) | `pending`/`paid` align; `included` has no direct equivalent (see status mapping). |
36
+ | `PayoutStatus` enum | `CommissionPayoutStatus` union (`COMMISSION_PAYOUT_STATUSES`) | 1:1. |
37
+ | `PayoutMethod` enum | `PayoutMethod` union (`PAYOUT_METHODS`) | Legacy values align; the new union adds `'other'`. |
38
+
39
+ New concepts with no legacy counterpart (adopt as you migrate):
40
+ `CommissionPlan(Collection)` (versioned calculation terms),
41
+ `EarningEvent(Collection)` (immutable evidence rows),
42
+ `CommissionAdjustment(Collection)` (append-only corrections),
43
+ `CommissionCalculationService`, `CommissionSettlementService`,
44
+ `CommissionBalanceService`, `CommissionPayoutService`, and the `money.ts`
45
+ helpers (`roundCents`, `centsToAmount`, `amountToCents`,
46
+ `calculateCommissionAmountCents`).
47
+
48
+ ## Table mapping
49
+
50
+ | Legacy table | New home |
51
+ |---|---|
52
+ | `partners` | `earners` (the financial identity: payout method/threshold/currency/status) **plus** role-model rows: `referrers` (`smrt-sales/referrals`) and/or `sales_representatives` (`smrt-sales/crm`), each holding `earner_id`. Publisher-style roles belong in your application domain (e.g. alongside your smrt-properties usage). |
53
+ | `commissions` (legacy shape) | `commissions` (new shape) — plus one `earning_events` evidence row per originating event. |
54
+ | `payouts` | `commission_payouts` |
55
+
56
+ ## Column mapping: `partners` → `earners` (+ role models)
57
+
58
+ | Legacy `partners` column | New home | Notes |
59
+ |---|---|---|
60
+ | `id`, `created_at`, `updated_at` | `earners.id`, … | Copy as-is (UUIDs preserved keeps FK rewrites trivial). |
61
+ | `profile_id` | `earners.profile_id` | Still a cross-package string ref to smrt-profiles. Also copy onto each role-model row. |
62
+ | `property_id` | application domain / role model | Publisher-specific; the neutral core has no property concept. Keep it on your publisher role model or in `earners.metadata`. |
63
+ | `partner_types` (JSON string array) | role-model **rows** | `'referrer'` → a `referrers` row; `'salesperson'` → a `sales_representatives` row; `'publisher'` → an application role model. Each row carries `earner_id`. One person with several roles keeps ONE earner. |
64
+ | `parent_partner_id` | plan components / role hierarchy | The parent-publisher waterfall is now expressed in `CommissionPlan` components (or commission splits), not on the account. Record the org relationship on your role models or in metadata. |
65
+ | `referred_by_id` | `smrt-sales/referrals` (`Referral` attribution) | Referral attribution is evidence-based (`ReferralTouch` → `Referral`), not a column on the account. |
66
+ | `parent_commission_share` | plan component rates / `share_fraction` | See the worked plan below. Note the legacy column was declared with an integer default (`= 0`) — audit stored values before converting. |
67
+ | `display_commission_rate`, `referral_commission_rate`, `sales_commission_rate` | `commission_plans.components` (JSON) | Per-type rates become plan components; see the worked plan below. Legacy values were true 0–1 decimals. |
68
+ | `payout_threshold` | `earners.payout_threshold_cents` | Same unit (integer cents), explicit `*Cents` name. |
69
+ | `payout_method` | `earners.payout_method` | Same string values; the new union adds `'other'`. |
70
+ | `currency` | `earners.currency` | Copy the explicit value (see currency note below). |
71
+ | `status` | `earners.status` | Same string values (`pending`/`active`/`suspended`). |
72
+ | `metadata` (JSON string) | `earners.metadata` | New default is `'{}'` rather than `''`. |
73
+ | — | `earners.tenant_id` | New, nullable. Legacy rows migrate as `NULL` (= global; matches the old cross-tenant behavior). |
74
+ | — | `earners.display_name` | New; backfill from your profile data. |
75
+ | — | `earners.payout_schedule_key` | New; defaults to `'manual'`. |
76
+
77
+ ## Column mapping: `commissions` (legacy) → `commissions` (new) + `earning_events`
78
+
79
+ Each legacy commission row becomes a new `commissions` row, and each distinct
80
+ originating ad event becomes ONE `earning_events` evidence row shared by the
81
+ commissions it produced.
82
+
83
+ | Legacy `commissions` column | New home | Notes |
84
+ |---|---|---|
85
+ | `event_id` (smrt-ads `AdEvent` ref) | `earning_events` row: `source_kind = 'ad_event'`, `source_id = <event_id>`, plus `commissions.source_kind`/`source_id` (copied) | Create the event with `event_kind` of your choosing (e.g. `'ad_revenue'`), `occurred_at = event_timestamp`, `gross_amount_cents = gross_revenue`, and a `dedupe_key` (e.g. `'ad_event:' \|\| event_id`) — the natural key that makes re-runs idempotent. |
86
+ | `partner_id` | `commissions.earner_id` | FK now points at `earners`. |
87
+ | `commission_type` | `commissions.component_key` | `'display'`/`'referral'`/`'sales'`/`'parent'`/`'overhead'` map naturally onto plan component keys (see the worked plan); also set `plan_key`/`plan_version` to the migration plan you create. |
88
+ | `gross_revenue` | `commissions.base_amount_cents` (and `earning_events.gross_amount_cents`) | Same unit (integer cents). `basis = 'gross'`. |
89
+ | `commission_rate` | `commissions.rate` | **Type change.** New `rate` is DECIMAL in 0–1. The legacy column was declared INTEGER (`= 0` default), i.e. basis points on strict-typed databases — divide by 10,000 (`5000` → `0.50`). Audit first: SQLite deployments may hold fractional REALs despite the INTEGER affinity; if stored values are already 0–1 decimals, copy as-is. |
90
+ | `commission_amount` | `commissions.amount_cents` | Same unit (integer cents). |
91
+ | `currency` | `commissions.currency` | Copy the explicit value. |
92
+ | `payout_id` | `commissions.payout_id` | Now points at `commission_payouts`. |
93
+ | `status` | `commissions.status` | See status mapping below. |
94
+ | `event_timestamp` | `earning_events.occurred_at` | The commission itself carries lifecycle timestamps instead (`earned_at`/`approved_at`/`payable_at`/`paid_at`). |
95
+ | `network_id`, `site_id`, `campaign_id` | `metadata` JSON (recommended) or your own `source_kind`/`source_id` conventions | The neutral core has no ad-network vocabulary. Put them in `earning_events.metadata`/`commissions.metadata` (e.g. `{"networkId": …, "siteId": …, "campaignId": …}`) for aggregate queries, or encode them in `source_kind` conventions if they identify the source. |
96
+ | `metadata` | `commissions.metadata` | New default `'{}'`. |
97
+ | — | `commissions.dedupe_key` | New, required natural key — e.g. `` `${event.dedupe_key}:${plan_key}@${plan_version}:${component_key}:${earner_id}:0` `` (the calculation service's format). |
98
+ | — | `commissions.share_fraction` (`1.0`), `split_group_id`, `terms_snapshot_kind`/`terms_snapshot_id`, `calculation_trace` | New audit/split fields; safe defaults shown. A backfilled `calculation_trace` naming your migration plan keeps historical amounts reproducible. |
99
+ | — | `commissions.tenant_id` | Nullable; legacy rows migrate as `NULL`. |
100
+
101
+ ## Column mapping: `payouts` → `commission_payouts`
102
+
103
+ | Legacy `payouts` column | New column | Notes |
104
+ |---|---|---|
105
+ | `partner_id` | `earner_id` | |
106
+ | `period_start`, `period_end` | same | Now nullable. |
107
+ | `display_earnings`, `referral_earnings`, `sales_earnings`, `parent_earnings`, `overhead_earnings` | `commission_total_cents` (sum) | The per-type breakdown columns are gone. `commission_total_cents = display + referral + sales + parent + overhead`; per-type breakdowns are now **queries** over the settled rows: `SELECT component_key, SUM(amount_cents) FROM commissions WHERE payout_id = … GROUP BY component_key`. |
108
+ | — | `adjustment_total_cents` | New (signed); `0` for migrated batches. |
109
+ | `total_amount` | `total_amount_cents` | Save-time invariant: `total_amount_cents = commission_total_cents + adjustment_total_cents`. |
110
+ | `currency` | `currency` | Copy the explicit value. |
111
+ | `invoice_id` | `invoice_id` | Still a cross-package string ref to a commerce Invoice. |
112
+ | `status` | `status` | 1:1 (see below). |
113
+ | `payment_reference` | `payment_reference` | |
114
+ | `paid_at` | `paid_at` | |
115
+ | `notes` | `notes` | |
116
+ | `metadata` | `metadata` | New default `'{}'`. |
117
+ | — | `payout_method` | New on the batch (defaulted from the Earner). Backfill from the legacy partner's `payout_method`. |
118
+ | — | `provider_ref` | New; empty for migrated rows. |
119
+ | — | `idempotency_key` | New, required natural key — e.g. `` `${earner_id}:${currency}:${period_end ISO date}` `` (the payout service's default format). |
120
+ | — | `tenant_id` | Nullable; legacy rows migrate as `NULL`. |
121
+
122
+ ## Status mappings
123
+
124
+ Commission (`pending → earned → approved → payable → paid` is the new strict
125
+ forward chain):
126
+
127
+ | Legacy `commissions.status` | New `commissions.status` | Notes |
128
+ |---|---|---|
129
+ | `pending` | `pending` | |
130
+ | `included` | `payable` **with `payout_id` set** | No direct equivalent — "included in a payout batch" is now the combination of the `payable` state and a stamped `payout_id`. Set `payable_at` from the payout's creation time if you need a timestamp. |
131
+ | `paid` | `paid` | Backfill `paid_at` from the payout's `paid_at`. Intermediate `earned_at`/`approved_at`/`payable_at` may be left `NULL` for historical rows. |
132
+
133
+ Payout statuses map 1:1: `pending → pending`, `approved → approved`,
134
+ `processing → processing`, `completed → completed`, `failed → failed`.
135
+
136
+ Partner → Earner statuses map 1:1: `pending`/`active`/`suspended`.
137
+
138
+ ## Rates → CommissionPlan components (worked example)
139
+
140
+ Legacy per-partner rate columns become a versioned `CommissionPlan`. The old
141
+ per-event waterfall was:
142
+
143
+ - display → publisher: `gross × display_commission_rate` (default 0.50)
144
+ - referral → referrer: `gross × referral_commission_rate` (default 0.05)
145
+ - sales → salesperson: `gross × sales_commission_rate × (1 − parent_commission_share)` (default 0.10 × 0.80 = 0.08)
146
+ - parent → parent publisher: `gross × sales_commission_rate × parent_commission_share` (default 0.10 × 0.20 = 0.02)
147
+
148
+ A plan whose `components` JSON reproduces that waterfall (using the default
149
+ legacy rates; substitute each partner's actual columns):
150
+
151
+ ```json
152
+ [
153
+ { "key": "display", "trigger": "ad_revenue", "basis": "gross", "rate": 0.5 },
154
+ { "key": "referral", "trigger": "ad_revenue", "basis": "gross", "rate": 0.05 },
155
+ { "key": "sales", "trigger": "ad_revenue", "basis": "gross", "rate": 0.08 },
156
+ { "key": "parent", "trigger": "ad_revenue", "basis": "gross", "rate": 0.02 },
157
+ { "key": "overhead", "trigger": "ad_revenue", "basis": "gross", "rate": 0.35 }
158
+ ]
159
+ ```
160
+
161
+ Each earning event is then calculated per earner with the component(s) that
162
+ earner is entitled to (`CommissionCalculationService.calculateForEvent`):
163
+ the publisher's Earner gets `display`, the referrer's gets `referral`, the
164
+ salesperson's gets `sales`, the parent publisher's gets `parent`.
165
+ Alternatively, keep ONE `sales` component at the full legacy rate (0.10) and
166
+ express the parent share as a split: two commissions sharing a
167
+ `split_group_id` with `share_fraction` 0.8 (salesperson) and 0.2 (parent).
168
+ Splits keep the calculation trace closer to the source terms; flat components
169
+ keep the plan closer to the legacy columns — both are valid.
170
+
171
+ Because legacy rates were **per partner**, either mint one plan per distinct
172
+ rate tuple (e.g. `plan_key = 'affiliates-legacy'`, one version per tuple) or
173
+ one plan per partner (`plan_key = 'partner-<id>'`). Plans are immutable once
174
+ active; rate changes are new versions, not edits.
175
+
176
+ ## Behavioral changes
177
+
178
+ - **Tenancy**: every new model is `@TenantScoped({ mode: 'optional' })` with
179
+ a nullable `tenant_id`. The legacy models were deliberately NOT
180
+ tenant-scoped (cross-tenant ad network); migrate legacy rows with
181
+ `tenant_id = NULL`, which the new stack treats as global and preserves the
182
+ old visibility. Sales-era rows are tenant-owned by default.
183
+ - **Currency**: new models default to `USD`; the legacy models defaulted to
184
+ `CAD`. Migrated rows keep whatever explicit value their `currency` column
185
+ held (the default only affects newly created rows) — copy the column, never
186
+ rely on the default.
187
+ - **Lifecycle**: the commission chain is now strict and forward-only —
188
+ `pending → earned → approved → payable → paid`, enforced at save time.
189
+ There is no generated update/delete on commissions, earning events, or
190
+ payouts; corrections are **append-only** `CommissionAdjustment` rows
191
+ (`refund`/`credit`/`chargeback`/`dispute`/`correction`) — an earned or paid
192
+ commission is never edited.
193
+ - **Balances are computed, not stored**: the legacy
194
+ `sumPendingByPartner()`-style aggregates become
195
+ `CommissionBalanceService.getBalance(earnerId, currency)` (payable +
196
+ unsettled adjustments), and payout batches are minted idempotently by
197
+ `CommissionPayoutService.createPayoutBatch`.
198
+ - **Idempotency**: earning events, commissions, and payouts all carry natural
199
+ keys (`dedupe_key` / `idempotency_key`); retried ingestion or settlement
200
+ upserts instead of duplicating.
201
+ - **Integer cents are kept**: all monetary fields remain integer cents, now
202
+ uniformly suffixed `*Cents`; rates are decimals in 0–1.
203
+
204
+ ## Example SQL sketches (Postgres-flavored — TEMPLATES, not runnable as-is)
205
+
206
+ Adjust table/column names to your deployment, wrap in a transaction, and
207
+ verify counts. These assume you keep legacy UUIDs so downstream FK rewrites
208
+ are mechanical.
209
+
210
+ ```sql
211
+ -- 1) partners → earners (financial identity; tenant stays NULL = global)
212
+ INSERT INTO earners (
213
+ id, created_at, updated_at, tenant_id, profile_id, display_name,
214
+ status, payout_method, payout_threshold_cents, payout_schedule_key,
215
+ currency, metadata
216
+ )
217
+ SELECT
218
+ p.id, p.created_at, p.updated_at, NULL, p.profile_id, '',
219
+ p.status, p.payout_method, p.payout_threshold, 'manual',
220
+ p.currency, COALESCE(NULLIF(p.metadata, ''), '{}')
221
+ FROM partners p;
222
+
223
+ -- 1b) role rows from the partner_types JSON array (repeat per role kind)
224
+ INSERT INTO sales_representatives (id, tenant_id, profile_id, earner_id, ...)
225
+ SELECT gen_random_uuid(), NULL, p.profile_id, p.id, ...
226
+ FROM partners p
227
+ WHERE p.partner_types::jsonb ? 'salesperson';
228
+ -- 'referrer' → referrers (smrt-sales/referrals); 'publisher' → your app's
229
+ -- publisher role model (property_id lives there, not on the earner).
230
+
231
+ -- 2) legacy commissions → earning_events (one row per distinct ad event)
232
+ INSERT INTO earning_events (
233
+ id, tenant_id, event_kind, occurred_at, source_kind, source_id,
234
+ gross_amount_cents, currency, dedupe_key, metadata
235
+ )
236
+ SELECT DISTINCT ON (c.event_id)
237
+ gen_random_uuid(), NULL, 'ad_revenue', c.event_timestamp,
238
+ 'ad_event', c.event_id,
239
+ c.gross_revenue, c.currency, 'ad_event:' || c.event_id,
240
+ jsonb_build_object(
241
+ 'networkId', c.network_id, 'siteId', c.site_id, 'campaignId', c.campaign_id
242
+ )::text
243
+ FROM commissions_legacy c
244
+ ORDER BY c.event_id, c.event_timestamp;
245
+
246
+ -- 3) legacy commissions → new commissions
247
+ -- NOTE the rate conversion: legacy commission_rate was an INTEGER column
248
+ -- (basis points) — divide by 10000.0. If your deployment actually stored
249
+ -- 0–1 decimals (SQLite affinity), copy the value unchanged instead.
250
+ INSERT INTO commissions (
251
+ id, created_at, updated_at, tenant_id, earner_id, earning_event_id,
252
+ plan_key, plan_version, component_key, basis, base_amount_cents, rate,
253
+ share_fraction, amount_cents, currency, status, payable_at, paid_at,
254
+ payout_id, source_kind, source_id, calculation_trace, dedupe_key, metadata
255
+ )
256
+ SELECT
257
+ c.id, c.created_at, c.updated_at, NULL, c.partner_id, ee.id,
258
+ 'affiliates-legacy', 1, c.commission_type, 'gross', c.gross_revenue,
259
+ c.commission_rate / 10000.0,
260
+ 1.0, c.commission_amount, c.currency,
261
+ CASE c.status WHEN 'included' THEN 'payable' ELSE c.status END,
262
+ CASE WHEN c.status IN ('included', 'paid') THEN c.updated_at END,
263
+ CASE WHEN c.status = 'paid' THEN pay.paid_at END,
264
+ NULLIF(c.payout_id, ''), 'ad_event', c.event_id,
265
+ '{}', ee.dedupe_key || ':affiliates-legacy@1:' || c.commission_type
266
+ || ':' || c.partner_id || ':0',
267
+ COALESCE(NULLIF(c.metadata, ''), '{}')
268
+ FROM commissions_legacy c
269
+ JOIN earning_events ee
270
+ ON ee.source_kind = 'ad_event' AND ee.source_id = c.event_id
271
+ LEFT JOIN payouts pay ON pay.id = c.payout_id;
272
+
273
+ -- 4) payouts → commission_payouts (per-type columns collapse into the
274
+ -- commission total; breakdowns become GROUP BY component_key queries)
275
+ INSERT INTO commission_payouts (
276
+ id, created_at, updated_at, tenant_id, earner_id, period_start, period_end,
277
+ commission_total_cents, adjustment_total_cents, total_amount_cents,
278
+ currency, payout_method, status, payment_reference, provider_ref, paid_at,
279
+ invoice_id, notes, idempotency_key, metadata
280
+ )
281
+ SELECT
282
+ po.id, po.created_at, po.updated_at, NULL, po.partner_id,
283
+ po.period_start, po.period_end,
284
+ po.display_earnings + po.referral_earnings + po.sales_earnings
285
+ + po.parent_earnings + po.overhead_earnings,
286
+ 0,
287
+ po.total_amount,
288
+ po.currency, p.payout_method, po.status, po.payment_reference, '',
289
+ po.paid_at, po.invoice_id, po.notes,
290
+ po.partner_id || ':' || po.currency || ':'
291
+ || to_char(po.period_end, 'YYYY-MM-DD'),
292
+ COALESCE(NULLIF(po.metadata, ''), '{}')
293
+ FROM payouts po
294
+ JOIN partners p ON p.id = po.partner_id;
295
+ ```
296
+
297
+ Post-migration checks worth running: per-earner sums
298
+ (`SUM(amount_cents)` grouped by `earner_id`/`status`) against the legacy
299
+ per-partner sums; `total_amount_cents = commission_total_cents +
300
+ adjustment_total_cents` on every payout; and every migrated `payable`/`paid`
301
+ commission carrying a `payout_id` that exists.
302
+
303
+ ## Legacy collection helpers
304
+
305
+ The legacy query helpers have close equivalents; the network-scoped ones do
306
+ not (network vocabulary moved to metadata/source conventions):
307
+
308
+ | Legacy helper | New surface |
309
+ |---|---|
310
+ | `PartnerCollection.findByProfile()` | `EarnerCollection.findByProfile()` |
311
+ | `PartnerCollection.findByProperty()/findByParent()/findByReferrer()` | role-model / application queries |
312
+ | `CommissionCollection.findByPartner()` | `CommissionCollection.findByEarner()` |
313
+ | `CommissionCollection.findByEvent()` | `CommissionCollection.findByEvent()` (takes the `earning_event_id`) |
314
+ | `CommissionCollection.findByPayout()/findByStatus()` | same names |
315
+ | `CommissionCollection.sumPendingByPartner()` | `CommissionBalanceService.getBalance()` / `CommissionCollection.sumPayableByEarner()` |
316
+ | `CommissionCollection.getEarningsBreakdown()` | `GROUP BY component_key` over `commissions` |
317
+ | `CommissionCollection.findByNetwork*()/getSummaryByNetwork()/getPendingPayoutsByNetwork()` | metadata/source-convention queries in your application |
318
+ | `PayoutCollection.findByPartner()` | `CommissionPayoutCollection.findByEarner()` |
319
+ | `Payout.approve()/markProcessing()/complete()/fail()` | same methods on `CommissionPayout` (plus `resetFromFailed()`); they mutate without saving — the caller saves |
320
+ | `Commission.calculateAmount(gross, rate)` | `calculateCommissionAmountCents(baseCents, rate, shareFraction?)` from `@happyvertical/smrt-sales` |
package/README.md CHANGED
@@ -1,118 +1,46 @@
1
1
  # @happyvertical/smrt-affiliates
2
2
 
3
- Affiliate partner and commission tracking models for the SMRT framework. Manages multi-type partners (publisher/salesperson/referrer), multi-tier commission attribution, and payout batch processing.
4
-
5
- ## Installation
3
+ > **DEPRECATED** — this package is a compatibility shim over
4
+ > [`@happyvertical/smrt-sales`](https://github.com/happyvertical/smrt/tree/main/packages/sales)
5
+ > and will be removed in a future major release.
6
+
7
+ The affiliate models that used to live here (`Partner`, `Commission`,
8
+ `Payout`) were generalized into the neutral commissions core of
9
+ `@happyvertical/smrt-sales`. This package now declares **no models and owns no
10
+ persistence** — it only re-exports the new classes under the legacy names so
11
+ existing imports keep compiling while you migrate:
12
+
13
+ | Legacy import (this package) | Actual class (`@happyvertical/smrt-sales`) |
14
+ |---|---|
15
+ | `Partner` / `PartnerCollection` | `Earner` / `EarnerCollection` |
16
+ | `Commission` / `CommissionCollection` | `Commission` / `CommissionCollection` (new shape) |
17
+ | `Payout` / `PayoutCollection` | `CommissionPayout` / `CommissionPayoutCollection` |
18
+ | `PartnerStatus`, `PartnerType`, `CommissionType`, `CommissionStatus`, `PayoutStatus`, `PayoutMethod` | frozen legacy-shaped const objects (see notes in `AGENTS.md`) |
19
+
20
+ ## Migrating
21
+
22
+ - **New code**: import from `@happyvertical/smrt-sales` directly — `Earner`,
23
+ `CommissionPlan`, `EarningEvent`, `Commission`, `CommissionAdjustment`,
24
+ `CommissionPayout`, the collections, services, and money helpers.
25
+ - **Partner roles**: publisher/salesperson/referrer role flags are replaced by
26
+ first-class role models — `Referrer` (`@happyvertical/smrt-sales/referrals`)
27
+ and `SalesRepresentative` (`@happyvertical/smrt-sales/crm`) — each holding an
28
+ `earnerId` that points at the shared financial account.
29
+ - **Data**: the full table/column/status mapping, behavioral changes (tenancy,
30
+ lifecycle, rate representation), and SQL migration sketches live in
31
+ [`MIGRATION.md`](./MIGRATION.md).
32
+
33
+ ## Installation (legacy consumers)
6
34
 
7
35
  ```bash
8
36
  pnpm add @happyvertical/smrt-affiliates
9
37
  ```
10
38
 
11
- ## Usage
12
-
13
39
  ```typescript
14
- import {
15
- Partner, PartnerCollection,
16
- Commission, CommissionCollection,
17
- Payout, PayoutCollection,
18
- PartnerType, CommissionType, CommissionStatus, PayoutStatus
19
- } from '@happyvertical/smrt-affiliates';
20
-
21
- // Register a publisher partner (earns display commissions)
22
- const partners = new PartnerCollection(db);
23
- const publisher = await partners.create({
24
- profileId: 'profile-uuid',
25
- propertyId: 'property-uuid',
26
- partnerTypes: JSON.stringify([PartnerType.PUBLISHER]),
27
- displayCommissionRate: 0.50,
28
- status: 'active',
29
- });
30
-
31
- // Attach a salesperson to the publisher
32
- // parentCommissionShare: 20% of sales commission goes to parent publisher
33
- const salesperson = await partners.create({
34
- profileId: 'sales-profile-uuid',
35
- parentPartnerId: publisher.id,
36
- partnerTypes: JSON.stringify([PartnerType.SALESPERSON]),
37
- salesCommissionRate: 0.10,
38
- parentCommissionShare: 0.20,
39
- status: 'active',
40
- });
41
- // Effective sales rate: 0.10 * (1 - 0.20) = 0.08
42
- salesperson.getEffectiveSalesRate(); // 0.08
43
-
44
- // Record a commission (all monetary values in integer cents)
45
- const commissions = new CommissionCollection(db);
46
- await commissions.create({
47
- eventId: 'adevent-uuid',
48
- partnerId: publisher.id,
49
- commissionType: CommissionType.DISPLAY,
50
- grossRevenue: 1000, // $10.00
51
- commissionRate: 0.50,
52
- commissionAmount: Commission.calculateAmount(1000, 0.50), // 500 cents
53
- currency: 'CAD',
54
- status: CommissionStatus.PENDING,
55
- });
56
-
57
- // Create a payout batch for the publisher
58
- const payouts = new PayoutCollection(db);
59
- const payout = await payouts.create({
60
- partnerId: publisher.id,
61
- periodStart: new Date('2024-01-01'),
62
- periodEnd: new Date('2024-01-31'),
63
- displayEarnings: 25000, // $250.00
64
- referralEarnings: 500, // $5.00
65
- salesEarnings: 0,
66
- parentEarnings: 0,
67
- totalAmount: 25500, // $255.00
68
- currency: 'CAD',
69
- status: PayoutStatus.PENDING,
70
- });
71
-
72
- // Payout lifecycle: PENDING -> APPROVED -> PROCESSING -> COMPLETED (or FAILED)
73
- payout.approve();
74
- payout.markProcessing();
75
- payout.complete('transfer-ref-123');
76
- await payout.save();
40
+ // Still compiles — these are the smrt-sales classes under their old names.
41
+ import { Partner, PartnerCollection, Payout } from '@happyvertical/smrt-affiliates';
77
42
  ```
78
43
 
79
- ### Commission Types
80
-
81
- Each ad event can generate up to four commissions:
82
-
83
- | Type | Recipient | Description |
84
- |------|-----------|-------------|
85
- | `DISPLAY` | Publisher | Site owner earns share of impression revenue |
86
- | `REFERRAL` | Referrer | Partner who referred the publisher |
87
- | `SALES` | Salesperson | Partner who brought in the advertiser |
88
- | `PARENT` | Parent publisher | Share of salesperson's commission |
89
-
90
- ## API
91
-
92
- ### Models
93
-
94
- | Export | Description |
95
- |--------|------------|
96
- | `Partner` | Affiliate partner with multi-type roles, commission rates, payout threshold, and parent hierarchy |
97
- | `Commission` | Immutable revenue attribution record (no delete). `calculateAmount(grossRevenue, rate)` static helper |
98
- | `Payout` | Aggregated payment batch with status lifecycle and per-type earnings breakdown |
99
-
100
- ### Collections
101
-
102
- `PartnerCollection`, `CommissionCollection`, `PayoutCollection`
103
-
104
- ### Enums
105
-
106
- | Export | Values |
107
- |--------|--------|
108
- | `PartnerType` | `publisher`, `salesperson`, `referrer` |
109
- | `PartnerStatus` | `pending`, `active`, `suspended` |
110
- | `CommissionType` | `display`, `referral`, `sales`, `parent` |
111
- | `CommissionStatus` | `pending`, `included`, `paid` |
112
- | `PayoutStatus` | `pending`, `approved`, `processing`, `completed`, `failed` |
113
- | `PayoutMethod` | `bank_transfer`, `check`, `paypal`, `credit` |
114
-
115
- ## Dependencies
44
+ ## License
116
45
 
117
- - `@happyvertical/smrt-core` -- ORM and code generation
118
- - Peer: `@happyvertical/smrt-ads`, `@happyvertical/smrt-commerce`, `@happyvertical/smrt-profiles`, `@happyvertical/smrt-properties`
46
+ MIT