Skip to content

feat(billing): take back affiliate commission when a referred payment is refunded - #4280

Merged
baktun14 merged 4 commits into
mainfrom
feat/billing-reverse-affiliate-commission
Nov 11, 2026
Merged

baktun14 merged 4 commits into
mainfrom
feat/billing-reverse-affiliate-commission

Conversation

@baktun14

@baktun14 baktun14 commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Why

Closes CON-1192. Without this, someone could pay, collect the affiliate commission and then get a refund, and repeat it.

What

  • A refund on a referred payment (applyRefund) and a lost dispute (new charge.dispute.closed handler, new dispute_lost_at column, migration 0129) queue the same SyncAffiliateCommission job. The job takes back the matching share of the commission: floor(commission * netPaid / amount) is kept, the rest is reversed, and the total reversed never exceeds the commission.
  • The affiliate's balance is reduced by the difference and never goes below 0. Any shortfall is logged, and billing history shows the commission as partly or fully refunded. Tracks affiliate_commission_reversed.
  • Reversals apply even when the flag is off or the affiliate is revoked. Only a settlement can grant commission. When a refund lands before the grant job runs, the grant pays the net amount in a single top-up.
  • reduceWalletBalance now locks the wallet it reduces (the team's or the member's own), as topUpWallet does, so a reversal and a grant on the same wallet don't overwrite each other.
  • The reversal debits the affiliate through main's refund debit, keyed by the commission row and its reversed totals, so a redelivered reversal never debits twice. The commission row is locked FOR NO KEY UPDATE, because that debit references it from another connection.
  • The reversal comes out of the affiliate's personal organization, where the commission was filed. A commission whose affiliate account was deleted is left alone.
  • No commission is paid to an affiliate whose wallet is abuse-locked.
  • A lost dispute still leaves the payer's own balance alone, the same as today.

Ops before turning affiliate_program on: add charge.dispute.closed to the Stripe webhook endpoint's events.

Testing

Unit specs for partial and full refunds, a lost dispute, a won dispute, a repeated sync, a revoked affiliate, the flag off, a refund before the grant, the abuse lock and rounding. Integration specs through the real job worker. Functional webhook specs for a 40% refund (200 of 500 taken back) and a lost dispute (fully reversed, payer untouched). Mutation score on changed lines: 100%.

@baktun14
baktun14 requested a review from a team as a code owner October 8, 2026 14:35

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@coderabbitai

coderabbitai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 24 billable files and costs up to $6.00.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 10 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration

Commits

Reviewing files that changed from the base of the PR and between c9ad6ac and 0950fbf.

Files selected for processing: 24

  • apps/api/drizzle/0129_dispute_lost_at.sql
  • apps/api/drizzle/meta/0129_snapshot.json
  • apps/api/drizzle/meta/_journal.json
  • apps/api/src/affiliate/services/affiliate-commission/affiliate-commission.service.integration.ts
  • apps/api/src/affiliate/services/affiliate-commission/affiliate-commission.service.spec.ts
  • apps/api/src/affiliate/services/affiliate-commission/affiliate-commission.service.ts
  • apps/api/src/affiliate/services/affiliate-commission/sync-affiliate-commission.handler.integration.ts
  • apps/api/src/affiliate/services/affiliate-commission/sync-affiliate-commission.handler.spec.ts
  • apps/api/src/affiliate/services/affiliate-commission/sync-affiliate-commission.handler.ts
  • apps/api/src/affiliate/services/affiliate-commission/sync-affiliate-commission.job.ts
  • apps/api/src/billing/model-schemas/stripe-transaction/stripe-transaction.schema.ts
  • apps/api/src/billing/repositories/stripe-transaction/stripe-transaction.repository.integration.ts
  • apps/api/src/billing/repositories/stripe-transaction/stripe-transaction.repository.ts
  • apps/api/src/billing/services/refill/refill.service.spec.ts
  • apps/api/src/billing/services/refill/refill.service.ts
  • apps/api/src/billing/services/refund-debit/refund-debit.service.integration.ts
  • apps/api/src/billing/services/stripe-transaction/stripe-transaction.service.integration.ts
  • apps/api/src/billing/services/stripe-transaction/stripe-transaction.service.spec.ts
  • apps/api/src/billing/services/stripe-transaction/stripe-transaction.service.ts
  • apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.spec.ts
  • apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.ts
  • apps/api/src/core/services/analytics/analytics.service.ts
  • apps/api/test/functional/stripe-webhook.spec.ts
  • apps/api/test/seeders/database-stripe-transaction.seeder.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

@baktun14

Copy link
Copy Markdown
Contributor Author

@claude review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Code review skipped — your organization's remaining monthly code review budget is too low to start another review, so this review was not started.

An organization admin can view or raise the monthly spending cap at claude.ai/admin-settings/usage, on the Claude Code Review row under Services. The cap resets at the start of the next billing period.

Once the cap resets or is raised, someone with write access to this repository can comment @claude review on this pull request to trigger a review.

@baktun14
baktun14 force-pushed the feat/billing-grant-affiliate-commission branch from 2ac14fe to ec5e58f Compare November 4, 2026 07:29
@baktun14
baktun14 force-pushed the feat/billing-reverse-affiliate-commission branch from 05315db to d76364a Compare November 4, 2026 07:29
@baktun14
baktun14 force-pushed the feat/billing-grant-affiliate-commission branch from ec5e58f to 4067df0 Compare November 5, 2026 21:02
@baktun14
baktun14 force-pushed the feat/billing-reverse-affiliate-commission branch from d76364a to 8490638 Compare November 5, 2026 21:02
@baktun14
baktun14 force-pushed the feat/billing-grant-affiliate-commission branch from 4067df0 to a68301b Compare November 5, 2026 21:43
@baktun14
baktun14 force-pushed the feat/billing-reverse-affiliate-commission branch from 8490638 to 67c3b38 Compare November 5, 2026 21:43
@baktun14
baktun14 force-pushed the feat/billing-grant-affiliate-commission branch from a68301b to da3b26b Compare November 9, 2026 01:08
@baktun14
baktun14 force-pushed the feat/billing-reverse-affiliate-commission branch from 67c3b38 to 340549c Compare November 9, 2026 01:08
@baktun14
baktun14 changed the base branch from feat/billing-grant-affiliate-commission to main November 10, 2026 04:43
@baktun14
baktun14 force-pushed the feat/billing-reverse-affiliate-commission branch 2 times, most recently from 9ebe380 to 5df23d0 Compare November 10, 2026 04:50
@codecov

codecov Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.13%. Comparing base (c9ad6ac) to head (0950fbf).
⚠️ Report is 2 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4280      +/-   ##
==========================================
+ Coverage   94.93%   95.13%   +0.19%     
==========================================
  Files        2155     2048     -107     
  Lines       62826    60091    -2735     
  Branches    15726    15189     -537     
==========================================
- Hits        59646    57167    -2479     
+ Misses       2860     2620     -240     
+ Partials      320      304      -16     
Flag Coverage Δ *Carryforward flag
api 96.87% <100.00%> (+0.01%) ⬆️
deploy-web 93.63% <ø> (ø) Carriedforward from c9ad6ac
log-collector ?
notifications 96.68% <ø> (ø) Carriedforward from c9ad6ac
provider-console 81.68% <ø> (ø) Carriedforward from c9ad6ac
provider-inventory ?
provider-proxy 95.18% <ø> (ø) Carriedforward from c9ad6ac
tx-signer ?

*This pull request uses carry forward flags. Click here to find out more.

Files with missing lines Coverage Δ
...filiate-commission/affiliate-commission.service.ts 100.00% <100.00%> (ø)
...te-commission/sync-affiliate-commission.handler.ts 100.00% <100.00%> (ø)
...iliate-commission/sync-affiliate-commission.job.ts 100.00% <100.00%> (ø)
...as/stripe-transaction/stripe-transaction.schema.ts 66.66% <ø> (ø)
...tripe-transaction/stripe-transaction.repository.ts 89.32% <100.00%> (+0.21%) ⬆️
.../api/src/billing/services/refill/refill.service.ts 98.92% <100.00%> (+0.10%) ⬆️
...s/stripe-transaction/stripe-transaction.service.ts 95.87% <100.00%> (+0.20%) ⬆️
.../services/stripe-webhook/stripe-webhook.service.ts 100.00% <100.00%> (ø)
...i/src/core/services/analytics/analytics.service.ts 100.00% <ø> (ø)

... and 111 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@baktun14
baktun14 force-pushed the feat/billing-reverse-affiliate-commission branch from 5df23d0 to a2cf1d7 Compare November 10, 2026 05:36
@baktun14
baktun14 added this pull request to the merge queue Nov 10, 2026
@baktun14
baktun14 removed this pull request from the merge queue due to a manual request Nov 10, 2026
@baktun14
baktun14 force-pushed the feat/billing-reverse-affiliate-commission branch 2 times, most recently from c9943fb to 79a941d Compare November 11, 2026 03:35
… is refunded

A refund of a referred card payment, or a dispute the payer wins, now
takes the matching part of the affiliate's commission back out of their
Console credit.

charge.refunded queues the SyncAffiliateCommission job for card payments
when the delivery records a new refund amount. A new charge.dispute.closed
route records dispute_lost_at on the payment when the dispute is lost and
queues the same job. The payer's own balance is untouched by a dispute,
as before.

The sync now reconciles every commission it finds or grants. The
commission should be 5% of what the payer still paid (zero after a lost
dispute), rounded down, and the job takes back the difference from what
was already reversed: it updates amount_refunded on the commission row
(status refunded once fully reversed) and reduces the affiliate's
allowance, floored at zero. Repeated syncs take back nothing twice. A
payment refunded before its first sync is granted and then reversed in
the same run. Reversal ignores the flag and applies to revoked
affiliates.

- add stripe_transactions.dispute_lost_at (migration 0076)
- RefillService.reduceWalletBalance returns the shortfall it could not
  cover, logged with AFFILIATE_COMMISSION_REVERSED
- skip the grant with reason affiliate_abuse_locked when the affiliate's
  wallet is locked for abuse
- track affiliate_commission_reversed in Amplitude

Ops: add charge.dispute.closed to the Stripe webhook endpoint's events.

Refs CON-1192
…e top-up

A payment refunded or lost to a dispute before its commission sync ran
used to be granted in full and then reduced in the same job. If the
reduction failed, the row rolled back while the top-up stayed on chain,
and the retry granted it again. The grant now records the gross
commission with the refunded share already marked as taken back, and
tops up only what is left. Nothing is granted when nothing is left.

The sync job now says what queued it. Only a settlement may grant; a
refund or a dispute only takes back a commission that already exists,
so a skipped grant stays skipped and a refund that beats the settlement
job leaves the grant to it.

- take back each refund's share of the stored commission, capped at the
  commission, so a later rate change cannot over-reverse
- lock the wallet row in RefillService.reduceWalletBalance, as
  topUpWallet does, so a reversal and a grant on one wallet serialize
- round the reported shortfall up to whole cents

Refs CON-1192
…rsed total

Main now debits a refund once per transaction and refunded total, from a
connection of its own. The reversal passes the commission row and its
reversed totals as that key, so a redelivered reversal never debits the
affiliate twice. The commission row is locked FOR NO KEY UPDATE, because
the refund debit references it from that other connection and would
otherwise wait forever on the sync holding the lock.

A refund now queues a commission sync, so the refund debit integration
spec registers its handler.
@baktun14
baktun14 force-pushed the feat/billing-reverse-affiliate-commission branch from 79a941d to 0950fbf Compare November 11, 2026 03:54
@baktun14
baktun14 added this pull request to the merge queue Nov 11, 2026
Merged via the queue into main with commit 52266e4 Nov 11, 2026
81 of 83 checks passed
@baktun14
baktun14 deleted the feat/billing-reverse-affiliate-commission branch November 11, 2026 04:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant