feat(billing): USD offline bank-transfer payments

This commit is contained in:
Marshal
2026-08-08 13:37:05 +00:00
parent 83b9e32670
commit a42d32c27c
31 changed files with 923 additions and 11 deletions

View File

@@ -0,0 +1,38 @@
import { ContractsRepository } from './contracts.repository';
/**
* A ONE_TIME contract stops blocking a duplicate request only once its booking
* is PAID. The existing duplicate-guard spec stubs the repository out, so the
* candidate SQL itself is unchecked there — this pins the predicate.
*/
describe('findDuplicateCandidates ONE_TIME paid gate', () => {
const candidateSql = (): string => {
const conditions: string[] = [];
const qb = {
leftJoinAndSelect: () => qb,
where: () => qb,
andWhere: (condition: string) => {
if (typeof condition === 'string') conditions.push(condition);
return qb;
},
getMany: async () => [],
};
const repository = new ContractsRepository(
{ createQueryBuilder: () => qb } as never,
{} as never,
);
void repository.findDuplicateCandidates('company-1', 'svc-1');
return conditions.join(' AND ');
};
it('spends the contract on payment, not on the booking row existing', () => {
const sql = candidateSql();
expect(sql).toContain("contract.contract_kind <> 'ONE_TIME'");
// The gate: an unpaid booking must NOT free the lane.
expect(sql).toContain("b.payment_status = 'PAID'");
expect(sql).toContain('b.deleted_at IS NULL');
});
});

View File

@@ -104,15 +104,19 @@ export class ContractsRepository extends BaseRepository<Contract> {
.andWhere('contract.status NOT IN (:...terminal)', {
terminal: TERMINAL_CONTRACT_STATUSES,
})
// A ONE_TIME contract allows a single booking, so once that booking
// exists the contract is spent and can never carry another shipment.
// A ONE_TIME contract allows a single booking, so once that booking is
// PAID the contract is spent and can never carry another shipment.
// Without this it kept blocking new requests on the same service type +
// route until its validity lapsed — locking a customer out of a lane for
// the rest of the term after one completed shipment.
// Payment is the gate, not the booking row: a DRAFT or abandoned unpaid
// booking must keep the contract blocking, otherwise a customer holds an
// unpaid booking and requests an identical contract alongside it.
.andWhere(
`(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS (
SELECT 1 FROM freight.bookings b
WHERE b.contract_id = contract.id AND b.deleted_at IS NULL
AND b.payment_status = 'PAID'
))`,
)
.getMany();