M0: foundation — monorepo, PostGIS schema, deck query, app shell
Greenfield scaffold for Linkder, a swipe-to-hire marketplace for local
professional services.
- pnpm/turbo monorepo: apps/web, packages/{shared,db}
- Postgres 16 + PostGIS via docker compose (ports 5442/6389 to avoid
clashing with other local stacks)
- Drizzle schema, 23 tables, geography(Point,4326) with GiST indexes
- Domain core in packages/shared: integer-cent money, status transition
graphs, deck ranking weights, cancellation policy — 46 unit tests
- Deck query: filtering in Postgres on the GiST index, ranking in JS so
the weights stay tunable — 18 integration tests against a seeded DB
- Deterministic seed placing pros at known distances, including three
that must NOT appear on a deck (out of radius, unverified, away)
- Next.js 15 app shell with a working swipe deck
- CI: typecheck, lint, test, build against live postgres+redis
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@linkder/shared",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { FREE_CANCELLATION_HOURS, LATE_CANCELLATION_FEE_BPS } from './constants';
|
||||
import type { Cents } from './money';
|
||||
import { platformFee } from './money';
|
||||
|
||||
export type CancelledBy = 'client' | 'pro' | 'admin';
|
||||
|
||||
export interface CancellationOutcome {
|
||||
/** Returned to the client. */
|
||||
refundCents: Cents;
|
||||
/** Kept by the platform and/or paid to the pro as a late-cancellation fee. */
|
||||
feeCents: Cents;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who eats the cost of a cancellation.
|
||||
*
|
||||
* A pro cancelling is always a full refund — they took the slot and dropped it,
|
||||
* that is not the client's problem. A client cancelling inside the window pays a
|
||||
* fee, because the pro has already turned down other work for that slot.
|
||||
*/
|
||||
export function cancellationOutcome(args: {
|
||||
amountCents: Cents;
|
||||
scheduledStart: Date;
|
||||
cancelledBy: CancelledBy;
|
||||
now?: Date;
|
||||
}): CancellationOutcome {
|
||||
const { amountCents, scheduledStart, cancelledBy } = args;
|
||||
const now = args.now ?? new Date();
|
||||
|
||||
if (cancelledBy !== 'client') {
|
||||
return {
|
||||
refundCents: amountCents,
|
||||
feeCents: 0,
|
||||
reason: `Cancelled by ${cancelledBy} — full refund`,
|
||||
};
|
||||
}
|
||||
|
||||
const hoursUntil = (scheduledStart.getTime() - now.getTime()) / 3_600_000;
|
||||
if (hoursUntil >= FREE_CANCELLATION_HOURS) {
|
||||
return {
|
||||
refundCents: amountCents,
|
||||
feeCents: 0,
|
||||
reason: `Cancelled ${Math.floor(hoursUntil)}h ahead — free cancellation`,
|
||||
};
|
||||
}
|
||||
|
||||
const feeCents = platformFee(amountCents, LATE_CANCELLATION_FEE_BPS);
|
||||
return {
|
||||
refundCents: amountCents - feeCents,
|
||||
feeCents,
|
||||
reason: `Cancelled inside the ${FREE_CANCELLATION_HOURS}h window — late cancellation fee applies`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/** Product rules that get tuned. Keep them in one place — you will change these weekly. */
|
||||
|
||||
/** Max open (pending) requests a client can have out on a single job. Stops city-spraying. */
|
||||
export const MAX_OPEN_REQUESTS_PER_JOB = 5;
|
||||
|
||||
/** How long a pro has to respond before a request auto-expires. */
|
||||
export const REQUEST_TTL_HOURS = {
|
||||
now: 12,
|
||||
this_week: 48,
|
||||
flexible: 48,
|
||||
} as const;
|
||||
|
||||
/** Client has this long to confirm completion before it auto-confirms and pays out. */
|
||||
export const AUTO_CONFIRM_HOURS = 72;
|
||||
|
||||
/** A quote is only good for this long. */
|
||||
export const QUOTE_VALIDITY_HOURS = 72;
|
||||
|
||||
/** Cards prefetched per deck page. */
|
||||
export const DECK_PAGE_SIZE = 20;
|
||||
|
||||
/** Free cancellation window before the booked slot. Inside it, a fee applies. */
|
||||
export const FREE_CANCELLATION_HOURS = 24;
|
||||
export const LATE_CANCELLATION_FEE_BPS = 2500; // 25% of the quote
|
||||
|
||||
/** Default platform commission. Overridden by PLATFORM_FEE_BPS env at runtime. */
|
||||
export const DEFAULT_PLATFORM_FEE_BPS = 1500; // 15%
|
||||
|
||||
export const MIN_QUOTE_CENTS = 500; // €5 — below this, escrow overhead isn't worth it
|
||||
export const MAX_QUOTE_CENTS = 2_000_000; // €20,000 sanity ceiling
|
||||
|
||||
/** Pro service radius bounds, metres. */
|
||||
export const MIN_SERVICE_RADIUS_M = 1_000;
|
||||
export const MAX_SERVICE_RADIUS_M = 50_000;
|
||||
export const DEFAULT_SERVICE_RADIUS_M = 15_000;
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './constants';
|
||||
export * from './money';
|
||||
export * from './state-machines';
|
||||
export * from './ranking';
|
||||
export * from './cancellation';
|
||||
export * from './schemas';
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Money is always integer minor units (cents). Never floats — 0.1 + 0.2 problems
|
||||
* become customer support tickets when they happen to someone's payout.
|
||||
*/
|
||||
|
||||
export type Cents = number;
|
||||
|
||||
export class MoneyError extends Error {}
|
||||
|
||||
export function assertCents(value: number, label = 'amount'): asserts value is Cents {
|
||||
if (!Number.isInteger(value)) throw new MoneyError(`${label} must be an integer, got ${value}`);
|
||||
if (value < 0) throw new MoneyError(`${label} must not be negative, got ${value}`);
|
||||
if (!Number.isSafeInteger(value)) throw new MoneyError(`${label} exceeds safe integer range`);
|
||||
}
|
||||
|
||||
/** Basis points: 1500 bps = 15%. */
|
||||
export type Bps = number;
|
||||
|
||||
/**
|
||||
* Platform commission. Rounds half-up so the platform never takes a fraction of a
|
||||
* cent more than stated, and the pro's share absorbs the remainder.
|
||||
*/
|
||||
export function platformFee(amount: Cents, feeBps: Bps): Cents {
|
||||
assertCents(amount);
|
||||
if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 10_000) {
|
||||
throw new MoneyError(`feeBps must be an integer in [0, 10000], got ${feeBps}`);
|
||||
}
|
||||
return Math.round((amount * feeBps) / 10_000);
|
||||
}
|
||||
|
||||
/** What actually lands in the pro's connected account. */
|
||||
export function proPayout(amount: Cents, feeBps: Bps): Cents {
|
||||
return amount - platformFee(amount, feeBps);
|
||||
}
|
||||
|
||||
/** Split a charge into its parts. The two always sum back to `amount`. */
|
||||
export function splitCharge(amount: Cents, feeBps: Bps): { fee: Cents; payout: Cents } {
|
||||
const fee = platformFee(amount, feeBps);
|
||||
return { fee, payout: amount - fee };
|
||||
}
|
||||
|
||||
export function formatCents(amount: Cents, currency = 'EUR', locale = 'en-IE'): string {
|
||||
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount / 100);
|
||||
}
|
||||
|
||||
export function parseAmountToCents(input: string): Cents {
|
||||
const normalised = input.replace(/[^0-9.,-]/g, '').replace(',', '.');
|
||||
const parsed = Number.parseFloat(normalised);
|
||||
if (Number.isNaN(parsed)) throw new MoneyError(`Cannot parse "${input}" as an amount`);
|
||||
return Math.round(parsed * 100);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Deck ranking. Every pro shown to a client is scored here.
|
||||
*
|
||||
* These weights are the product. Expect to tune them weekly against booking
|
||||
* conversion — that is why they are one exported object and not sprinkled
|
||||
* through a SQL string.
|
||||
*/
|
||||
|
||||
export const RANKING_WEIGHTS = {
|
||||
rating: 0.35,
|
||||
responseRate: 0.25,
|
||||
proximity: 0.2,
|
||||
recency: 0.1,
|
||||
newProBoost: 0.1,
|
||||
} as const;
|
||||
|
||||
/** A pro with no reviews yet is treated as this rating, so they aren't buried at 0. */
|
||||
export const UNRATED_BASELINE = 4.0;
|
||||
/** Reviews needed before a pro's real rating fully replaces the baseline. */
|
||||
export const RATING_CONFIDENCE_N = 5;
|
||||
/** New pros get a decaying boost for this long so fresh supply gets seen. */
|
||||
export const NEW_PRO_GRACE_DAYS = 30;
|
||||
|
||||
export interface RankingInput {
|
||||
ratingAvg: number | null;
|
||||
ratingCount: number;
|
||||
/** 0..1 — accepted or declined within TTL, vs let expire. */
|
||||
responseRate: number | null;
|
||||
distanceM: number;
|
||||
serviceRadiusM: number;
|
||||
lastActiveAt: Date | null;
|
||||
createdAt: Date;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bayesian-smoothed rating: pulls a 5.0-from-one-review pro back toward the
|
||||
* baseline until they have a real track record. Without this, the deck is topped
|
||||
* by whoever got a single review from a friend.
|
||||
*/
|
||||
export function smoothedRating(ratingAvg: number | null, ratingCount: number): number {
|
||||
if (ratingAvg === null || ratingCount === 0) return UNRATED_BASELINE;
|
||||
const n = RATING_CONFIDENCE_N;
|
||||
return (ratingAvg * ratingCount + UNRATED_BASELINE * n) / (ratingCount + n);
|
||||
}
|
||||
|
||||
/** Linear falloff across the pro's own radius — near the edge is worth less than next door. */
|
||||
export function proximityScore(distanceM: number, serviceRadiusM: number): number {
|
||||
if (serviceRadiusM <= 0) return 0;
|
||||
return clamp01(1 - distanceM / serviceRadiusM);
|
||||
}
|
||||
|
||||
/** Active today = 1, decaying to 0 over two weeks. Dormant pros don't respond. */
|
||||
export function recencyScore(lastActiveAt: Date | null, now: Date): number {
|
||||
if (!lastActiveAt) return 0;
|
||||
const days = (now.getTime() - lastActiveAt.getTime()) / 86_400_000;
|
||||
return clamp01(1 - days / 14);
|
||||
}
|
||||
|
||||
/** Decaying head start for pros in their first month. */
|
||||
export function newProBoost(createdAt: Date, now: Date): number {
|
||||
const days = (now.getTime() - createdAt.getTime()) / 86_400_000;
|
||||
return clamp01(1 - days / NEW_PRO_GRACE_DAYS);
|
||||
}
|
||||
|
||||
/** Final deck score, 0..1. Higher sorts first. */
|
||||
export function score(input: RankingInput): number {
|
||||
const now = input.now ?? new Date();
|
||||
const w = RANKING_WEIGHTS;
|
||||
return (
|
||||
w.rating * (smoothedRating(input.ratingAvg, input.ratingCount) / 5) +
|
||||
w.responseRate * clamp01(input.responseRate ?? 0.5) +
|
||||
w.proximity * proximityScore(input.distanceM, input.serviceRadiusM) +
|
||||
w.recency * recencyScore(input.lastActiveAt, now) +
|
||||
w.newProBoost * newProBoost(input.createdAt, now)
|
||||
);
|
||||
}
|
||||
|
||||
function clamp01(n: number): number {
|
||||
if (Number.isNaN(n)) return 0;
|
||||
return Math.min(1, Math.max(0, n));
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
MAX_QUOTE_CENTS,
|
||||
MAX_SERVICE_RADIUS_M,
|
||||
MIN_QUOTE_CENTS,
|
||||
MIN_SERVICE_RADIUS_M,
|
||||
} from './constants';
|
||||
|
||||
export const urgencySchema = z.enum(['now', 'this_week', 'flexible']);
|
||||
export type Urgency = z.infer<typeof urgencySchema>;
|
||||
|
||||
export const roleSchema = z.enum(['client', 'pro', 'admin']);
|
||||
export type Role = z.infer<typeof roleSchema>;
|
||||
|
||||
export const swipeDirectionSchema = z.enum(['left', 'right']);
|
||||
export type SwipeDirection = z.infer<typeof swipeDirectionSchema>;
|
||||
|
||||
export const latLngSchema = z.object({
|
||||
lat: z.number().min(-90).max(90),
|
||||
lng: z.number().min(-180).max(180),
|
||||
});
|
||||
export type LatLng = z.infer<typeof latLngSchema>;
|
||||
|
||||
export const centsSchema = z.number().int().nonnegative();
|
||||
|
||||
export const createJobSchema = z
|
||||
.object({
|
||||
categoryId: z.string().uuid(),
|
||||
title: z.string().min(5).max(120),
|
||||
description: z.string().min(20).max(4000),
|
||||
photos: z.array(z.string().url()).max(8).default([]),
|
||||
urgency: urgencySchema,
|
||||
budgetMinCents: centsSchema.optional(),
|
||||
budgetMaxCents: centsSchema.optional(),
|
||||
location: latLngSchema,
|
||||
addressText: z.string().min(3).max(255),
|
||||
})
|
||||
.refine(
|
||||
(v) =>
|
||||
v.budgetMinCents === undefined ||
|
||||
v.budgetMaxCents === undefined ||
|
||||
v.budgetMinCents <= v.budgetMaxCents,
|
||||
{ message: 'Minimum budget cannot exceed maximum', path: ['budgetMinCents'] },
|
||||
);
|
||||
export type CreateJobInput = z.infer<typeof createJobSchema>;
|
||||
|
||||
export const proProfileSchema = z.object({
|
||||
headline: z.string().min(5).max(100),
|
||||
bio: z.string().min(30).max(2000),
|
||||
hourlyRateCents: centsSchema.max(MAX_QUOTE_CENTS),
|
||||
yearsExperience: z.number().int().min(0).max(70),
|
||||
categoryIds: z.array(z.string().uuid()).min(1, 'Pick at least one trade').max(5),
|
||||
location: latLngSchema,
|
||||
serviceRadiusM: z.number().int().min(MIN_SERVICE_RADIUS_M).max(MAX_SERVICE_RADIUS_M),
|
||||
});
|
||||
export type ProProfileInput = z.infer<typeof proProfileSchema>;
|
||||
|
||||
export const swipeSchema = z.object({
|
||||
jobId: z.string().uuid(),
|
||||
proId: z.string().uuid(),
|
||||
direction: swipeDirectionSchema,
|
||||
});
|
||||
export type SwipeInput = z.infer<typeof swipeSchema>;
|
||||
|
||||
export const createQuoteSchema = z
|
||||
.object({
|
||||
matchId: z.string().uuid(),
|
||||
kind: z.enum(['fixed', 'hourly']),
|
||||
amountCents: centsSchema.min(MIN_QUOTE_CENTS).max(MAX_QUOTE_CENTS),
|
||||
hoursEstimate: z.number().positive().max(1000).optional(),
|
||||
scope: z.string().min(10).max(2000),
|
||||
})
|
||||
.refine((v) => v.kind !== 'hourly' || v.hoursEstimate !== undefined, {
|
||||
message: 'An hourly quote needs an hours estimate',
|
||||
path: ['hoursEstimate'],
|
||||
});
|
||||
export type CreateQuoteInput = z.infer<typeof createQuoteSchema>;
|
||||
|
||||
export const createBookingSchema = z
|
||||
.object({
|
||||
matchId: z.string().uuid(),
|
||||
quoteId: z.string().uuid(),
|
||||
scheduledStart: z.coerce.date(),
|
||||
scheduledEnd: z.coerce.date(),
|
||||
})
|
||||
.refine((v) => v.scheduledEnd > v.scheduledStart, {
|
||||
message: 'End must be after start',
|
||||
path: ['scheduledEnd'],
|
||||
})
|
||||
.refine((v) => v.scheduledStart.getTime() > Date.now() - 60_000, {
|
||||
message: 'Cannot book a slot in the past',
|
||||
path: ['scheduledStart'],
|
||||
});
|
||||
export type CreateBookingInput = z.infer<typeof createBookingSchema>;
|
||||
|
||||
export const createReviewSchema = z.object({
|
||||
bookingId: z.string().uuid(),
|
||||
rating: z.number().int().min(1).max(5),
|
||||
body: z.string().min(10).max(1500),
|
||||
});
|
||||
export type CreateReviewInput = z.infer<typeof createReviewSchema>;
|
||||
|
||||
export const sendMessageSchema = z.object({
|
||||
matchId: z.string().uuid(),
|
||||
body: z.string().min(1).max(4000),
|
||||
attachments: z.array(z.string().url()).max(5).default([]),
|
||||
});
|
||||
export type SendMessageInput = z.infer<typeof sendMessageSchema>;
|
||||
|
||||
export const credentialSchema = z.object({
|
||||
kind: z.enum(['id', 'licence', 'insurance']),
|
||||
fileUrl: z.string().url(),
|
||||
issuer: z.string().max(120).optional(),
|
||||
expiresAt: z.coerce.date().optional(),
|
||||
});
|
||||
export type CredentialInput = z.infer<typeof credentialSchema>;
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Every status transition in the product lives here as a pure function.
|
||||
* tRPC mutations MUST route through `assertTransition` — nothing gets to jump
|
||||
* from `scheduled` straight to `completed` because a client sent a crafted payload.
|
||||
*/
|
||||
|
||||
export class TransitionError extends Error {
|
||||
constructor(
|
||||
readonly entity: string,
|
||||
readonly from: string,
|
||||
readonly to: string,
|
||||
) {
|
||||
super(`Illegal ${entity} transition: ${from} -> ${to}`);
|
||||
this.name = 'TransitionError';
|
||||
}
|
||||
}
|
||||
|
||||
type Graph<T extends string> = Readonly<Record<T, readonly T[]>>;
|
||||
|
||||
export const JOB_STATUSES = ['open', 'matched', 'booked', 'completed', 'cancelled'] as const;
|
||||
export type JobStatus = (typeof JOB_STATUSES)[number];
|
||||
|
||||
export const REQUEST_STATUSES = ['pending', 'accepted', 'declined', 'expired'] as const;
|
||||
export type RequestStatus = (typeof REQUEST_STATUSES)[number];
|
||||
|
||||
export const QUOTE_STATUSES = ['sent', 'accepted', 'declined', 'withdrawn', 'expired'] as const;
|
||||
export type QuoteStatus = (typeof QUOTE_STATUSES)[number];
|
||||
|
||||
export const BOOKING_STATUSES = [
|
||||
'scheduled',
|
||||
'in_progress',
|
||||
'awaiting_confirmation',
|
||||
'completed',
|
||||
'cancelled',
|
||||
'disputed',
|
||||
] as const;
|
||||
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
|
||||
|
||||
export const PAYMENT_STATUSES = [
|
||||
'pending',
|
||||
'held',
|
||||
'released',
|
||||
'refunded',
|
||||
'partially_refunded',
|
||||
'failed',
|
||||
] as const;
|
||||
export type PaymentStatus = (typeof PAYMENT_STATUSES)[number];
|
||||
|
||||
export const VERIFICATION_STATUSES = [
|
||||
'draft',
|
||||
'pending',
|
||||
'verified',
|
||||
'rejected',
|
||||
'suspended',
|
||||
] as const;
|
||||
export type VerificationStatus = (typeof VERIFICATION_STATUSES)[number];
|
||||
|
||||
const JOB_GRAPH: Graph<JobStatus> = {
|
||||
open: ['matched', 'cancelled'],
|
||||
matched: ['booked', 'open', 'cancelled'], // back to `open` when every match falls through
|
||||
booked: ['completed', 'cancelled'],
|
||||
completed: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
const REQUEST_GRAPH: Graph<RequestStatus> = {
|
||||
pending: ['accepted', 'declined', 'expired'],
|
||||
accepted: [],
|
||||
declined: [],
|
||||
expired: [],
|
||||
};
|
||||
|
||||
const QUOTE_GRAPH: Graph<QuoteStatus> = {
|
||||
sent: ['accepted', 'declined', 'withdrawn', 'expired'],
|
||||
accepted: [],
|
||||
declined: [],
|
||||
withdrawn: [],
|
||||
expired: [],
|
||||
};
|
||||
|
||||
const BOOKING_GRAPH: Graph<BookingStatus> = {
|
||||
scheduled: ['in_progress', 'cancelled'],
|
||||
in_progress: ['awaiting_confirmation', 'cancelled', 'disputed'],
|
||||
awaiting_confirmation: ['completed', 'disputed'],
|
||||
completed: ['disputed'], // a dispute can still be raised inside the window
|
||||
cancelled: [],
|
||||
disputed: ['completed', 'cancelled'],
|
||||
};
|
||||
|
||||
const PAYMENT_GRAPH: Graph<PaymentStatus> = {
|
||||
pending: ['held', 'failed'],
|
||||
held: ['released', 'refunded', 'partially_refunded'],
|
||||
released: ['refunded', 'partially_refunded'],
|
||||
refunded: [],
|
||||
partially_refunded: ['refunded'],
|
||||
failed: ['pending'],
|
||||
};
|
||||
|
||||
const VERIFICATION_GRAPH: Graph<VerificationStatus> = {
|
||||
draft: ['pending'],
|
||||
pending: ['verified', 'rejected'],
|
||||
verified: ['suspended'],
|
||||
rejected: ['pending'],
|
||||
suspended: ['verified', 'rejected'],
|
||||
};
|
||||
|
||||
const GRAPHS = {
|
||||
job: JOB_GRAPH,
|
||||
request: REQUEST_GRAPH,
|
||||
quote: QUOTE_GRAPH,
|
||||
booking: BOOKING_GRAPH,
|
||||
payment: PAYMENT_GRAPH,
|
||||
verification: VERIFICATION_GRAPH,
|
||||
} as const;
|
||||
|
||||
export type Entity = keyof typeof GRAPHS;
|
||||
|
||||
export function canTransition(entity: Entity, from: string, to: string): boolean {
|
||||
const graph = GRAPHS[entity] as Graph<string>;
|
||||
const allowed = graph[from];
|
||||
return allowed !== undefined && allowed.includes(to);
|
||||
}
|
||||
|
||||
export function assertTransition(entity: Entity, from: string, to: string): void {
|
||||
if (!canTransition(entity, from, to)) throw new TransitionError(entity, from, to);
|
||||
}
|
||||
|
||||
export function nextStates(entity: Entity, from: string): readonly string[] {
|
||||
const graph = GRAPHS[entity] as Graph<string>;
|
||||
return graph[from] ?? [];
|
||||
}
|
||||
|
||||
export function isTerminal(entity: Entity, state: string): boolean {
|
||||
return nextStates(entity, state).length === 0;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cancellationOutcome } from '../src/cancellation';
|
||||
|
||||
const NOW = new Date('2026-06-01T12:00:00Z');
|
||||
const inHours = (h: number) => new Date(NOW.getTime() + h * 3_600_000);
|
||||
|
||||
describe('cancellationOutcome', () => {
|
||||
it('refunds in full when the client cancels well ahead', () => {
|
||||
const out = cancellationOutcome({
|
||||
amountCents: 20_000,
|
||||
scheduledStart: inHours(48),
|
||||
cancelledBy: 'client',
|
||||
now: NOW,
|
||||
});
|
||||
expect(out.refundCents).toBe(20_000);
|
||||
expect(out.feeCents).toBe(0);
|
||||
});
|
||||
|
||||
it('charges a fee when the client cancels inside the window', () => {
|
||||
const out = cancellationOutcome({
|
||||
amountCents: 20_000,
|
||||
scheduledStart: inHours(3),
|
||||
cancelledBy: 'client',
|
||||
now: NOW,
|
||||
});
|
||||
expect(out.feeCents).toBe(5_000); // 25%
|
||||
expect(out.refundCents).toBe(15_000);
|
||||
});
|
||||
|
||||
it('treats exactly 24h out as still free', () => {
|
||||
const out = cancellationOutcome({
|
||||
amountCents: 20_000,
|
||||
scheduledStart: inHours(24),
|
||||
cancelledBy: 'client',
|
||||
now: NOW,
|
||||
});
|
||||
expect(out.feeCents).toBe(0);
|
||||
});
|
||||
|
||||
it('never charges the client when the pro drops the job', () => {
|
||||
const out = cancellationOutcome({
|
||||
amountCents: 20_000,
|
||||
scheduledStart: inHours(1),
|
||||
cancelledBy: 'pro',
|
||||
now: NOW,
|
||||
});
|
||||
expect(out.refundCents).toBe(20_000);
|
||||
expect(out.feeCents).toBe(0);
|
||||
});
|
||||
|
||||
it('refunds in full on an admin cancellation', () => {
|
||||
const out = cancellationOutcome({
|
||||
amountCents: 20_000,
|
||||
scheduledStart: inHours(1),
|
||||
cancelledBy: 'admin',
|
||||
now: NOW,
|
||||
});
|
||||
expect(out.refundCents).toBe(20_000);
|
||||
});
|
||||
|
||||
it('always splits the full amount, whatever the branch', () => {
|
||||
for (const hours of [-5, 0, 1, 23.9, 24, 100]) {
|
||||
const out = cancellationOutcome({
|
||||
amountCents: 12_345,
|
||||
scheduledStart: inHours(hours),
|
||||
cancelledBy: 'client',
|
||||
now: NOW,
|
||||
});
|
||||
expect(out.refundCents + out.feeCents).toBe(12_345);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MoneyError, formatCents, parseAmountToCents, platformFee, proPayout, splitCharge } from '../src/money';
|
||||
|
||||
describe('platformFee', () => {
|
||||
it('takes the stated percentage', () => {
|
||||
expect(platformFee(10_000, 1500)).toBe(1500);
|
||||
expect(platformFee(15_000, 1200)).toBe(1800);
|
||||
});
|
||||
|
||||
it('rounds half-up to whole cents', () => {
|
||||
expect(platformFee(333, 1500)).toBe(50); // 49.95 -> 50
|
||||
expect(platformFee(1, 1500)).toBe(0); // 0.15 -> 0
|
||||
});
|
||||
|
||||
it('handles the boundaries', () => {
|
||||
expect(platformFee(10_000, 0)).toBe(0);
|
||||
expect(platformFee(10_000, 10_000)).toBe(10_000);
|
||||
});
|
||||
|
||||
it('rejects nonsense rates', () => {
|
||||
expect(() => platformFee(10_000, -1)).toThrow(MoneyError);
|
||||
expect(() => platformFee(10_000, 10_001)).toThrow(MoneyError);
|
||||
expect(() => platformFee(10_000, 12.5)).toThrow(MoneyError);
|
||||
});
|
||||
|
||||
it('rejects non-integer amounts — floats never reach Stripe', () => {
|
||||
expect(() => platformFee(99.99, 1500)).toThrow(MoneyError);
|
||||
expect(() => platformFee(-100, 1500)).toThrow(MoneyError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitCharge', () => {
|
||||
it('always sums back to the original amount', () => {
|
||||
for (const amount of [1, 7, 333, 999, 10_000, 123_457]) {
|
||||
const { fee, payout } = splitCharge(amount, 1500);
|
||||
expect(fee + payout).toBe(amount);
|
||||
}
|
||||
});
|
||||
|
||||
it('agrees with proPayout', () => {
|
||||
expect(splitCharge(20_000, 1500).payout).toBe(proPayout(20_000, 1500));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAmountToCents', () => {
|
||||
it('parses the shapes a human types', () => {
|
||||
expect(parseAmountToCents('150')).toBe(15_000);
|
||||
expect(parseAmountToCents('150.50')).toBe(15_050);
|
||||
expect(parseAmountToCents('150,50')).toBe(15_050);
|
||||
expect(parseAmountToCents('€150.50')).toBe(15_050);
|
||||
});
|
||||
|
||||
it('rounds to the nearest cent rather than truncating', () => {
|
||||
expect(parseAmountToCents('10.999')).toBe(1100);
|
||||
});
|
||||
|
||||
it('throws on junk', () => {
|
||||
expect(() => parseAmountToCents('abc')).toThrow(MoneyError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCents', () => {
|
||||
it('renders whole currency units', () => {
|
||||
expect(formatCents(15_000)).toContain('150');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
UNRATED_BASELINE,
|
||||
newProBoost,
|
||||
proximityScore,
|
||||
recencyScore,
|
||||
score,
|
||||
smoothedRating,
|
||||
} from '../src/ranking';
|
||||
|
||||
const NOW = new Date('2026-06-01T12:00:00Z');
|
||||
const daysAgo = (n: number) => new Date(NOW.getTime() - n * 86_400_000);
|
||||
|
||||
describe('smoothedRating', () => {
|
||||
it('falls back to the baseline for an unrated pro', () => {
|
||||
expect(smoothedRating(null, 0)).toBe(UNRATED_BASELINE);
|
||||
});
|
||||
|
||||
it('does not let one 5-star review top the deck', () => {
|
||||
const oneReview = smoothedRating(5, 1);
|
||||
const manyReviews = smoothedRating(4.8, 50);
|
||||
expect(oneReview).toBeLessThan(manyReviews);
|
||||
});
|
||||
|
||||
it('converges on the true rating as reviews accumulate', () => {
|
||||
expect(smoothedRating(4.8, 500)).toBeCloseTo(4.8, 1);
|
||||
});
|
||||
|
||||
it('pulls a single bad review up toward the baseline too', () => {
|
||||
expect(smoothedRating(1, 1)).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('proximityScore', () => {
|
||||
it('is 1 next door and 0 at the radius edge', () => {
|
||||
expect(proximityScore(0, 10_000)).toBe(1);
|
||||
expect(proximityScore(10_000, 10_000)).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps beyond the radius rather than going negative', () => {
|
||||
expect(proximityScore(50_000, 10_000)).toBe(0);
|
||||
});
|
||||
|
||||
it('handles a zero radius without dividing by zero', () => {
|
||||
expect(proximityScore(100, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recencyScore', () => {
|
||||
it('rewards active pros and decays dormant ones to zero', () => {
|
||||
expect(recencyScore(NOW, NOW)).toBe(1);
|
||||
expect(recencyScore(daysAgo(7), NOW)).toBeCloseTo(0.5, 1);
|
||||
expect(recencyScore(daysAgo(30), NOW)).toBe(0);
|
||||
});
|
||||
|
||||
it('scores a pro who has never been active at zero', () => {
|
||||
expect(recencyScore(null, NOW)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('newProBoost', () => {
|
||||
it('gives fresh supply a decaying head start', () => {
|
||||
expect(newProBoost(NOW, NOW)).toBe(1);
|
||||
expect(newProBoost(daysAgo(15), NOW)).toBeCloseTo(0.5, 1);
|
||||
expect(newProBoost(daysAgo(60), NOW)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('score', () => {
|
||||
const base = {
|
||||
ratingAvg: 4.5,
|
||||
ratingCount: 20,
|
||||
responseRate: 0.9,
|
||||
distanceM: 2_000,
|
||||
serviceRadiusM: 10_000,
|
||||
lastActiveAt: NOW,
|
||||
createdAt: daysAgo(200),
|
||||
now: NOW,
|
||||
};
|
||||
|
||||
it('always lands in 0..1', () => {
|
||||
expect(score(base)).toBeGreaterThan(0);
|
||||
expect(score(base)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('ranks the closer of two identical pros higher', () => {
|
||||
const near = score({ ...base, distanceM: 500 });
|
||||
const far = score({ ...base, distanceM: 9_000 });
|
||||
expect(near).toBeGreaterThan(far);
|
||||
});
|
||||
|
||||
it('ranks the more responsive of two identical pros higher', () => {
|
||||
expect(score({ ...base, responseRate: 0.95 })).toBeGreaterThan(
|
||||
score({ ...base, responseRate: 0.2 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not bury a brand-new pro beneath an established one', () => {
|
||||
const newbie = score({ ...base, ratingAvg: null, ratingCount: 0, responseRate: null, createdAt: NOW });
|
||||
expect(newbie).toBeGreaterThan(0.3);
|
||||
});
|
||||
|
||||
it('assumes an unknown response rate is average rather than terrible', () => {
|
||||
const unknown = score({ ...base, responseRate: null });
|
||||
const terrible = score({ ...base, responseRate: 0 });
|
||||
expect(unknown).toBeGreaterThan(terrible);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BOOKING_STATUSES,
|
||||
TransitionError,
|
||||
assertTransition,
|
||||
canTransition,
|
||||
isTerminal,
|
||||
nextStates,
|
||||
} from '../src/state-machines';
|
||||
|
||||
describe('booking transitions', () => {
|
||||
it('walks the happy path', () => {
|
||||
expect(canTransition('booking', 'scheduled', 'in_progress')).toBe(true);
|
||||
expect(canTransition('booking', 'in_progress', 'awaiting_confirmation')).toBe(true);
|
||||
expect(canTransition('booking', 'awaiting_confirmation', 'completed')).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to skip straight to completed — the payout guard', () => {
|
||||
expect(canTransition('booking', 'scheduled', 'completed')).toBe(false);
|
||||
expect(() => assertTransition('booking', 'scheduled', 'completed')).toThrow(TransitionError);
|
||||
});
|
||||
|
||||
it('cannot resurrect a cancelled booking', () => {
|
||||
expect(isTerminal('booking', 'cancelled')).toBe(true);
|
||||
expect(canTransition('booking', 'cancelled', 'scheduled')).toBe(false);
|
||||
});
|
||||
|
||||
it('still allows a dispute after completion', () => {
|
||||
expect(canTransition('booking', 'completed', 'disputed')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unknown states instead of silently allowing them', () => {
|
||||
expect(canTransition('booking', 'nonsense', 'completed')).toBe(false);
|
||||
expect(nextStates('booking', 'nonsense')).toEqual([]);
|
||||
});
|
||||
|
||||
it('every declared status appears in the graph', () => {
|
||||
for (const status of BOOKING_STATUSES) {
|
||||
expect(() => nextStates('booking', status)).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('payment transitions', () => {
|
||||
it('holds before it releases', () => {
|
||||
expect(canTransition('payment', 'pending', 'held')).toBe(true);
|
||||
expect(canTransition('payment', 'held', 'released')).toBe(true);
|
||||
});
|
||||
|
||||
it('never releases straight from pending — money only moves after capture', () => {
|
||||
expect(canTransition('payment', 'pending', 'released')).toBe(false);
|
||||
});
|
||||
|
||||
it('lets a failed payment be retried', () => {
|
||||
expect(canTransition('payment', 'failed', 'pending')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a full refund as final', () => {
|
||||
expect(isTerminal('payment', 'refunded')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verification transitions', () => {
|
||||
it('requires review before a pro is verified', () => {
|
||||
expect(canTransition('verification', 'draft', 'verified')).toBe(false);
|
||||
expect(canTransition('verification', 'draft', 'pending')).toBe(true);
|
||||
expect(canTransition('verification', 'pending', 'verified')).toBe(true);
|
||||
});
|
||||
|
||||
it('can suspend a verified pro and reinstate them', () => {
|
||||
expect(canTransition('verification', 'verified', 'suspended')).toBe(true);
|
||||
expect(canTransition('verification', 'suspended', 'verified')).toBe(true);
|
||||
});
|
||||
|
||||
it('lets a rejected pro reapply', () => {
|
||||
expect(canTransition('verification', 'rejected', 'pending')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('request transitions', () => {
|
||||
it('is one-shot — an accepted request cannot change', () => {
|
||||
expect(isTerminal('request', 'accepted')).toBe(true);
|
||||
expect(canTransition('request', 'accepted', 'declined')).toBe(false);
|
||||
expect(canTransition('request', 'expired', 'accepted')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "dist", "noEmit": true },
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: { environment: 'node', include: ['test/**/*.test.ts'] },
|
||||
});
|
||||
Reference in New Issue
Block a user