Skip to content

Commit fb4fcf3

Browse files
authored
Merge pull request #1746 from Kwin-xeexee/cancellation-fee.service.ts
cancellation-fee.service.ts
2 parents 3d02a08 + 6a31b72 commit fb4fcf3

5 files changed

Lines changed: 68 additions & 16 deletions

File tree

‎backend/docs/payments-custody-model.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ This creates a **hybrid custody model**: non-custodial deposit (the shipper cont
1717

1818
We adopt a **hybrid (non-custodial deposit + admin-arbitrated release)** custody model.
1919

20+
### Cancellation Fees
21+
22+
Cancellation fees are based on shipment progress only when the shipper
23+
cancels, or an admin cancels on the shipper's behalf: cancellation is free
24+
while `PENDING`, retains 10% of the quoted price after `ACCEPTED`, and retains
25+
25% while `IN_TRANSIT`. A carrier-initiated cancellation is always fee-free,
26+
so the shipper receives the full escrow refund. An admin cancelling a
27+
`DISPUTED` shipment also refunds in full. The backend records the applied
28+
fee on the shipment and includes it in the escrow refund calculation.
29+
2030
### What the Admin Keypair Can Do
2131

2232
| Action | Authorized By | Effect |

‎backend/src/shipments/cancellation-fee.service.spec.ts‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
} from './cancellation-fee.service';
66
import { Shipment } from './entities/shipment.entity';
77
import { ShipmentStatus } from '../common/enums/shipment-status.enum';
8+
import { UserRole } from '../common/enums/role.enum';
89

910
function makeShipment(overrides: Partial<Shipment> = {}): Shipment {
1011
return {
@@ -116,7 +117,11 @@ describe('CancellationFeeService', () => {
116117
it('stamps the fee and an audit note onto the shipment', () => {
117118
const shipment = makeShipment({ status: ShipmentStatus.ACCEPTED });
118119

119-
const fee = service.applyTo(shipment, ShipmentStatus.ACCEPTED);
120+
const fee = service.applyTo(
121+
shipment,
122+
UserRole.SHIPPER,
123+
ShipmentStatus.ACCEPTED,
124+
);
120125

121126
expect(fee).toBe(20);
122127
expect(shipment.cancellationFee).toBe(20);
@@ -126,7 +131,7 @@ describe('CancellationFeeService', () => {
126131
it('defaults to the shipment’s current status', () => {
127132
const shipment = makeShipment({ status: ShipmentStatus.IN_TRANSIT });
128133

129-
expect(service.applyTo(shipment)).toBe(50);
134+
expect(service.applyTo(shipment, UserRole.SHIPPER)).toBe(50);
130135
expect(shipment.cancellationFee).toBe(50);
131136
});
132137

@@ -136,7 +141,7 @@ describe('CancellationFeeService', () => {
136141
notes: 'Handle with care — fragile electronics',
137142
});
138143

139-
service.applyTo(shipment);
144+
service.applyTo(shipment, UserRole.SHIPPER);
140145

141146
expect(shipment.notes).toBe(
142147
'Handle with care — fragile electronics\nCancellation fee: 50 USD',
@@ -146,22 +151,44 @@ describe('CancellationFeeService', () => {
146151
it('records a zero fee for a cancelled-before-accept shipment', () => {
147152
const shipment = makeShipment({ status: ShipmentStatus.PENDING });
148153

149-
expect(service.applyTo(shipment)).toBe(0);
154+
expect(service.applyTo(shipment, UserRole.SHIPPER)).toBe(0);
150155
expect(shipment.cancellationFee).toBe(0);
151156
expect(shipment.notes).toBe('Cancellation fee: 0 USD');
152157
});
153158

154159
it('refuses to apply a fee to a non-cancellable shipment', () => {
155160
const shipment = makeShipment({ status: ShipmentStatus.DELIVERED });
156161

157-
expect(() => service.applyTo(shipment)).toThrow(BadRequestException);
162+
expect(() => service.applyTo(shipment, UserRole.SHIPPER)).toThrow(
163+
BadRequestException,
164+
);
158165
expect(shipment.cancellationFee).toBeNull();
159166
});
160167

161168
it('coerces a stringified numeric price (Postgres numeric) safely', () => {
162169
const shipment = makeShipment({ price: '1000.00' as never });
163170

164-
expect(service.applyTo(shipment, ShipmentStatus.IN_TRANSIT)).toBe(250);
171+
expect(
172+
service.applyTo(
173+
shipment,
174+
UserRole.SHIPPER,
175+
ShipmentStatus.IN_TRANSIT,
176+
),
177+
).toBe(250);
178+
});
179+
180+
it('waives the fee when a carrier cancels', () => {
181+
const shipment = makeShipment({ status: ShipmentStatus.IN_TRANSIT });
182+
183+
expect(service.applyTo(shipment, UserRole.CARRIER)).toBe(0);
184+
expect(shipment.cancellationFee).toBe(0);
185+
expect(shipment.notes).toBe('Cancellation fee: 0 USD');
186+
});
187+
188+
it('uses the status tier when an admin cancels on the shipper side', () => {
189+
const shipment = makeShipment({ status: ShipmentStatus.ACCEPTED });
190+
191+
expect(service.applyTo(shipment, UserRole.ADMIN)).toBe(20);
165192
});
166193
});
167194

‎backend/src/shipments/cancellation-fee.service.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Injectable, BadRequestException } from '@nestjs/common';
22
import { Shipment } from './entities/shipment.entity';
33
import { ShipmentStatus } from '../common/enums/shipment-status.enum';
4+
import { UserRole } from '../common/enums/role.enum';
45

56
/**
67
* Share of the quoted price retained by the platform when a shipment is
@@ -85,15 +86,24 @@ export class CancellationFeeService {
8586
* Stamps the calculated fee onto `shipment` (in memory — the caller
8687
* persists it) and appends an audit line to the existing notes rather
8788
* than overwriting them, so a shipper's own notes survive a
88-
* cancellation.
89+
* cancellation. Carrier-initiated cancellations are fee-free; shipper
90+
* and admin cancellations use the status-based fee tier.
8991
*
92+
* @param actorRole role of the user initiating the cancellation.
9093
* @param previousStatus the status the shipment is being cancelled
9194
* *from*; defaults to the shipment's current status.
9295
* @returns the fee that was applied.
9396
*/
94-
applyTo(shipment: Shipment, previousStatus?: ShipmentStatus): number {
97+
applyTo(
98+
shipment: Shipment,
99+
actorRole: UserRole,
100+
previousStatus?: ShipmentStatus,
101+
): number {
95102
const from = previousStatus ?? shipment.status;
96-
const fee = this.calculateFee(Number(shipment.price), from);
103+
const fee =
104+
actorRole === UserRole.CARRIER
105+
? 0
106+
: this.calculateFee(Number(shipment.price), from);
97107
const note = this.describeFee(fee, shipment.currency);
98108

99109
shipment.cancellationFee = fee;

‎backend/src/shipments/shipments.service.spec.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -753,7 +753,7 @@ describe('ShipmentsService', () => {
753753
expect(shipmentRepo.save).not.toHaveBeenCalled();
754754
});
755755

756-
it('lets the assigned carrier cancel an in-transit shipment', async () => {
756+
it('lets the assigned carrier cancel an in-transit shipment without a fee', async () => {
757757
const carrier = makeUser({
758758
id: 'carrier-uuid-1',
759759
role: UserRole.CARRIER,
@@ -768,7 +768,11 @@ describe('ShipmentsService', () => {
768768
await service.cancel('shipment-uuid-1', carrier);
769769

770770
expect(shipmentRepo.save).toHaveBeenCalledWith(
771-
expect.objectContaining({ cancellationFee: 200 }),
771+
expect.objectContaining({ cancellationFee: 0 }),
772+
);
773+
expect(paymentsService.refundEscrowForShipment).toHaveBeenCalledWith(
774+
'shipment-uuid-1',
775+
0,
772776
);
773777
});
774778
});

‎backend/src/shipments/shipments.service.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -678,11 +678,11 @@ export class ShipmentsService {
678678
* Cancels a shipment, refunding escrow and retaining a tiered
679679
* cancellation fee.
680680
*
681-
* The fee rate is a function of the status being cancelled *from*
682-
* (see `CancellationFeeService`): free while PENDING, 10% once a carrier
683-
* has accepted, 25% while the cargo is IN_TRANSIT, and free for an
684-
* admin cancelling a disputed shipment. The fee is stamped on the
685-
* shipment and surfaced on every shipment read via
681+
* Shipper and admin cancellations use the status-based fee tier (free
682+
* while PENDING, 10% once a carrier has accepted, 25% while IN_TRANSIT,
683+
* and free for an admin cancelling a disputed shipment). Carrier-
684+
* initiated cancellations are always fee-free. The fee is stamped on
685+
* the shipment and surfaced on every shipment read via
686686
* `shipment.cancellationFee`.
687687
*
688688
* Ordering matters: the fee is computed before the settlement so a failing
@@ -718,6 +718,7 @@ export class ShipmentsService {
718718
const previousStatus = shipment.status;
719719
const cancellationFee = this.cancellationFeeService.applyTo(
720720
shipment,
721+
user.role,
721722
previousStatus,
722723
);
723724

0 commit comments

Comments
 (0)