Skip to content

Commit c34f028

Browse files
Banyel3claude
andcommitted
feat(api,rider): COD debt cap — pause dispatch when a rider owes too much
Prevents a rider accumulating unbounded platform cash (COD they collected but haven't deposited) and absconding. New platform-config field riderCodCapPhp (default ₱1,500, env RIDER_COD_CAP_PHP): once a rider's outstanding COD (collected − deposited) reaches the cap, they stop receiving new jobs until they deposit. - platform-config: riderCodCapPhp added to CONFIG_FIELDS, defaults, and the grouped values; migration adds the column (default 1500). - orders.repository: riderOutstandingCod(rider) for the manual gate; pickAutoDispatchRider now filters over-cap riders (batched two-groupBy, no per-rider round-trip) so auto-dispatch skips them too. - orders.service.assignRider: rejects an over-cap rider (parity with the auto-dispatch filter) — BadRequest naming the limit. - rider-cash: /me/cash now returns capPhp; the Cash tab shows "new jobs pause once you owe ₱X" and flips to "over the limit — deposit to resume" when hit. Note: ₱100 (as floated) would block a rider after one order — a single wash's COD exceeds it. ₱1,500 (~4-7 orders) bounds abscond loss while allowing a normal batch. The ~₱100 "carry allowance" (auto-net small debt vs weekly earnings) is a separate remittance-netting feature, deferred. New tests: over-cap assign rejected, just-under-cap allowed, balance carries the cap, config default. 136 api unit tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent aa01504 commit c34f028

12 files changed

Lines changed: 136 additions & 9 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- Rider COD debt cap: max outstanding cash a rider may carry before dispatch pauses.
2+
ALTER TABLE "PlatformConfig" ADD COLUMN "riderCodCapPhp" DECIMAL(12,2) NOT NULL DEFAULT 1500;

apps/api/prisma/schema.prisma

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,9 @@ model PlatformConfig {
368368
// P4a dispatch toggle: 1 = auto-assign a rider on Express booking, 0 = manual
369369
// dispatch only. Flag (0/1) to fit the numeric config pipeline.
370370
autoDispatchEnabled Int @default(0)
371+
// Max outstanding COD (collected − deposited) a rider may carry before new
372+
// jobs pause. Bounds the platform's exposure if a rider absconds with cash.
373+
riderCodCapPhp Decimal @default(1500) @db.Decimal(12, 2)
371374
updatedAt DateTime @updatedAt
372375
}
373376

apps/api/src/orders/orders.repository.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,22 @@ export class OrdersRepository {
9898
// or null if none. Runs inside the create tx so the load count is consistent.
9999
async pickAutoDispatchRider(
100100
tx: Prisma.TransactionClient,
101+
codCapPhp: number,
101102
): Promise<string | null> {
102103
const riders = await tx.user.findMany({
103104
// VERIFIED riders only — auto-dispatch never assigns an unverified rider.
104105
where: { roles: { has: 'RIDER' }, disabledAt: null, riderProfile: { status: 'VERIFIED' } },
105106
select: { id: true },
106107
});
107108
if (riders.length === 0) return null;
108-
const ids = riders.map((r) => r.id);
109+
// Drop riders over the COD debt cap — same gate as manual assign, so a
110+
// rider holding too much platform cash stops receiving new jobs.
111+
const ids = await this.filterUnderCodCap(
112+
tx,
113+
riders.map((r) => r.id),
114+
codCapPhp,
115+
);
116+
if (ids.length === 0) return null;
109117
const grouped = await tx.order.groupBy({
110118
by: ['assignedRiderId'],
111119
where: {
@@ -121,6 +129,51 @@ export class OrdersRepository {
121129
return selectLeastLoadedRider(ids, load);
122130
}
123131

132+
// Outstanding COD a rider still owes = cash they collected (paid COD on their
133+
// orders) minus what they've deposited back. Used by the debt-cap gate.
134+
async riderOutstandingCod(riderId: string): Promise<Prisma.Decimal> {
135+
const [collected, deposited] = await Promise.all([
136+
this.prisma.order.aggregate({
137+
_sum: { customerTotalPhp: true },
138+
where: { assignedRiderId: riderId, paidCashAt: { not: null } },
139+
}),
140+
this.prisma.riderCashDeposit.aggregate({
141+
_sum: { amountPhp: true },
142+
where: { riderId },
143+
}),
144+
]);
145+
const c = collected._sum.customerTotalPhp ?? new Prisma.Decimal(0);
146+
const d = deposited._sum.amountPhp ?? new Prisma.Decimal(0);
147+
return c.minus(d);
148+
}
149+
150+
// Keep only rider ids whose outstanding COD is strictly under the cap. Batched
151+
// (two groupBys) so auto-dispatch stays one round-trip per table, not per rider.
152+
private async filterUnderCodCap(
153+
tx: Prisma.TransactionClient,
154+
ids: string[],
155+
codCapPhp: number,
156+
): Promise<string[]> {
157+
const [collected, deposited] = await Promise.all([
158+
tx.order.groupBy({
159+
by: ['assignedRiderId'],
160+
where: { assignedRiderId: { in: ids }, paidCashAt: { not: null } },
161+
_sum: { customerTotalPhp: true },
162+
}),
163+
tx.riderCashDeposit.groupBy({
164+
by: ['riderId'],
165+
where: { riderId: { in: ids } },
166+
_sum: { amountPhp: true },
167+
}),
168+
]);
169+
const dep = new Map(deposited.map((d) => [d.riderId, Number(d._sum.amountPhp ?? 0)]));
170+
const col = new Map<string, number>();
171+
for (const c of collected) {
172+
if (c.assignedRiderId) col.set(c.assignedRiderId, Number(c._sum.customerTotalPhp ?? 0));
173+
}
174+
return ids.filter((id) => (col.get(id) ?? 0) - (dep.get(id) ?? 0) < codCapPhp);
175+
}
176+
124177
async isShopMember(userId: string, shopId: string): Promise<boolean> {
125178
const m = await this.prisma.shopMember.findUnique({
126179
where: { shopId_userId: { shopId, userId } },

apps/api/src/orders/orders.service.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ describe('OrdersService', () => {
136136
isShopMember: jest.fn(),
137137
userHasRole: jest.fn(),
138138
isRiderVerified: jest.fn().mockResolvedValue(true),
139+
riderOutstandingCod: jest.fn().mockResolvedValue(D('0')),
139140
lockShopDay: jest.fn().mockResolvedValue(undefined),
140141
countExpressOrdersForShopDay: jest.fn(),
141142
pickAutoDispatchRider: jest.fn().mockResolvedValue(null),
@@ -171,6 +172,7 @@ describe('OrdersService', () => {
171172
minOrderPricePhp: '0',
172173
platformFeePhp: '0',
173174
autoDispatchEnabled: 0, // OFF by default — existing tests stay BOOKED
175+
riderCodCapPhp: 1500,
174176
updatedAt: new Date(),
175177
};
176178
const config = {
@@ -560,6 +562,27 @@ describe('OrdersService', () => {
560562
).rejects.toBeInstanceOf(BadRequestException);
561563
});
562564

565+
it('rejects a rider over the COD debt cap (outstanding ≥ cap)', async () => {
566+
repo.userHasRole.mockResolvedValue(true);
567+
repo.isRiderVerified.mockResolvedValue(true);
568+
repo.riderOutstandingCod.mockResolvedValue(D('1500')); // == cap (1500)
569+
await expect(
570+
service.assignRider(makeUser(['ADMIN']), 'o1', { riderId: 'in-debt' }),
571+
).rejects.toBeInstanceOf(BadRequestException);
572+
});
573+
574+
it('allows a rider just under the cap', async () => {
575+
repo.userHasRole.mockResolvedValue(true);
576+
repo.isRiderVerified.mockResolvedValue(true);
577+
repo.riderOutstandingCod.mockResolvedValue(D('1499.99'));
578+
repo.findByIdForUpdate.mockResolvedValue(makeOrder({ status: 'BOOKED' }));
579+
repo.updateOrder.mockResolvedValue(makeOrder({ status: 'ASSIGNED' }));
580+
const out = await service.assignRider(makeUser(['ADMIN']), 'o1', {
581+
riderId: 'rider1',
582+
});
583+
expect(out.status).toBe('ASSIGNED');
584+
});
585+
563586
it('assigns and transitions BOOKED → ASSIGNED', async () => {
564587
repo.userHasRole.mockResolvedValue(true);
565588
repo.findByIdForUpdate.mockResolvedValue(makeOrder({ status: 'BOOKED' }));

apps/api/src/orders/orders.service.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ export class OrdersService {
348348
// available rider immediately; if none free (or disabled), the order stays
349349
// BOOKED for manual admin dispatch — the exception path.
350350
if (cfg.autoDispatchEnabled > 0) {
351-
const riderId = await this.repo.pickAutoDispatchRider(tx);
351+
const riderId = await this.repo.pickAutoDispatchRider(tx, cfg.riderCodCapPhp);
352352
if (riderId) {
353353
await this.repo.updateOrder(tx, order.id, {
354354
status: OrderStatus.ASSIGNED,
@@ -499,6 +499,16 @@ export class OrdersService {
499499
// draft/rejected rider (no verified license/ID) is never handed platform cash.
500500
const verified = await this.repo.isRiderVerified(dto.riderId);
501501
if (!verified) throw new BadRequestException('Rider is not verified yet');
502+
// Debt cap: a rider holding more than the configured outstanding COD stops
503+
// receiving new jobs until they deposit — bounds abscond risk. Parity with
504+
// the auto-dispatch filter below.
505+
const { riderCodCapPhp } = await this.config.getValues();
506+
const outstanding = await this.repo.riderOutstandingCod(dto.riderId);
507+
if (Number(outstanding) >= riderCodCapPhp) {
508+
throw new BadRequestException(
509+
`Rider is over the ₱${riderCodCapPhp} cash limit — they must deposit before taking new jobs`,
510+
);
511+
}
502512

503513
return this.prisma.$transaction(async (tx) => {
504514
const order = await this.repo.findByIdForUpdate(tx, orderId);

apps/api/src/platform-config/platform-config.service.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const row = (over: Partial<Record<string, unknown>> = {}) => ({
1818
minOrderPricePhp: 0,
1919
platformFeePhp: 0,
2020
autoDispatchEnabled: 0,
21+
riderCodCapPhp: 1500,
2122
updatedAt: new Date('2026-07-19T00:00:00Z'),
2223
toString() {
2324
return String((this as Record<string, unknown>).__v);

apps/api/src/platform-config/platform-config.service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const CONFIG_FIELDS = [
2929
'minOrderPricePhp',
3030
'platformFeePhp',
3131
'autoDispatchEnabled',
32+
'riderCodCapPhp',
3233
] as const;
3334

3435
export type ConfigField = (typeof CONFIG_FIELDS)[number];
@@ -47,6 +48,7 @@ export interface PlatformConfigValues {
4748
minOrderPricePhp: string;
4849
platformFeePhp: string;
4950
autoDispatchEnabled: number; // 1 = auto-assign rider on Express booking
51+
riderCodCapPhp: number; // max outstanding COD before a rider's dispatch pauses
5052
updatedAt: Date;
5153
}
5254

@@ -105,6 +107,7 @@ export class PlatformConfigService {
105107
minOrderPricePhp: this.envNum('MIN_ORDER_PRICE_PHP', 0),
106108
platformFeePhp: this.envNum('PLATFORM_FEE_PHP', 0),
107109
autoDispatchEnabled: this.envNum('AUTO_DISPATCH_ENABLED', 0),
110+
riderCodCapPhp: this.envNum('RIDER_COD_CAP_PHP', 1500),
108111
};
109112
}
110113

@@ -147,6 +150,7 @@ export class PlatformConfigService {
147150
minOrderPricePhp: r.minOrderPricePhp.toString(),
148151
platformFeePhp: r.platformFeePhp.toString(),
149152
autoDispatchEnabled: Number(r.autoDispatchEnabled),
153+
riderCodCapPhp: Number(r.riderCodCapPhp),
150154
updatedAt: r.updatedAt,
151155
};
152156
}

apps/api/src/riders/rider-cash.service.spec.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
22
import { Prisma } from '@prisma/client';
33
import { RiderCashService } from './rider-cash.service';
44
import type { RiderCashRepository } from './rider-cash.repository';
5+
import type { PlatformConfigService } from '../platform-config/platform-config.service';
6+
7+
// Config stub — only riderCodCapPhp is read by RiderCashService.balance().
8+
const configStub = {
9+
getValues: jest.fn().mockResolvedValue({ riderCodCapPhp: 1500 }),
10+
} as unknown as PlatformConfigService;
511

612
const D = (v: Prisma.Decimal.Value) => new Prisma.Decimal(v);
713

@@ -20,18 +26,19 @@ describe('RiderCashService', () => {
2026
listDeposits: jest.fn(),
2127
findRider: jest.fn(),
2228
} as unknown as jest.Mocked<RiderCashRepository>;
23-
service = new RiderCashService(repo);
29+
service = new RiderCashService(repo, configStub);
2430
});
2531

2632
describe('balance', () => {
27-
it('computes outstanding = collected − deposited', async () => {
33+
it('computes outstanding = collected − deposited, and includes the cap', async () => {
2834
repo.sumCollected.mockResolvedValue(D('1250.50'));
2935
repo.sumDeposited.mockResolvedValue(D('800.00'));
3036
expect(await service.balance('r1')).toEqual({
3137
riderId: 'r1',
3238
collectedPhp: '1250.50',
3339
depositedPhp: '800.00',
3440
outstandingPhp: '450.50',
41+
capPhp: '1500',
3542
});
3643
});
3744

apps/api/src/riders/rider-cash.service.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
22
import { Prisma } from '@prisma/client';
33
import { isUniqueViolation } from '../common/prisma-errors';
4+
import { PlatformConfigService } from '../platform-config/platform-config.service';
45
import { RiderCashRepository } from './rider-cash.repository';
56

67
export interface RiderCashBalance {
78
riderId: string;
89
collectedPhp: string; // COD taken from customers
910
depositedPhp: string; // handed back to the platform
1011
outstandingPhp: string; // still owed to the platform (collected − deposited)
12+
capPhp?: string; // max outstanding before dispatch pauses (rider's own view)
1113
}
1214

1315
/*
@@ -18,12 +20,18 @@ export interface RiderCashBalance {
1820
*/
1921
@Injectable()
2022
export class RiderCashService {
21-
constructor(private readonly repo: RiderCashRepository) {}
23+
constructor(
24+
private readonly repo: RiderCashRepository,
25+
private readonly config: PlatformConfigService,
26+
) {}
2227

2328
async balance(riderId: string): Promise<RiderCashBalance> {
24-
const collected = await this.repo.sumCollected(riderId);
25-
const deposited = await this.repo.sumDeposited(riderId);
26-
return this.shape(riderId, collected, deposited);
29+
const [collected, deposited, cfg] = await Promise.all([
30+
this.repo.sumCollected(riderId),
31+
this.repo.sumDeposited(riderId),
32+
this.config.getValues(),
33+
]);
34+
return { ...this.shape(riderId, collected, deposited), capPhp: String(cfg.riderCodCapPhp) };
2735
}
2836

2937
// All riders who have collected any COD, with their outstanding balance.

apps/api/src/riders/riders.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Module } from '@nestjs/common';
22
import { NotificationsModule } from '../notifications/notifications.module';
3+
import { PlatformConfigModule } from '../platform-config/platform-config.module';
34
import { RidersController } from './riders.controller';
45
import { RidersService } from './riders.service';
56
import { RiderCashController } from './rider-cash.controller';
@@ -12,7 +13,7 @@ import { AdminRidersController } from './admin-riders.controller';
1213
import { AdminRidersService } from './admin-riders.service';
1314

1415
@Module({
15-
imports: [NotificationsModule], // AdminRidersService notifies riders on verify/reject
16+
imports: [NotificationsModule, PlatformConfigModule], // notify on verify/reject; config for the COD cap
1617
controllers: [
1718
RidersController,
1819
RiderCashController,

0 commit comments

Comments
 (0)