@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 +46 -57
- package/MIGRATION.md +320 -0
- package/README.md +35 -107
- package/dist/index.d.ts +113 -960
- package/dist/index.js +37 -1319
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +4 -1961
- package/dist/smrt-knowledge.json +14 -966
- package/package.json +7 -8
package/AGENTS.md
CHANGED
|
@@ -1,62 +1,51 @@
|
|
|
1
1
|
# @happyvertical/smrt-affiliates
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
- **
|
|
62
|
-
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
- Peer: `@happyvertical/smrt-ads`, `@happyvertical/smrt-commerce`, `@happyvertical/smrt-profiles`, `@happyvertical/smrt-properties`
|
|
46
|
+
MIT
|