```ts
/**
 * Applies charges to buckets based on given quotas and returns the remaining allowances and refused charges.
 *
 * @param caps - A mapping from bucket name to a pair of the bucket's whole allowance and the largest single charge that bucket will take.
 * @param charges - An array of triples representing charge id, bucket name, and amount.
 * @returns An object holding left, the allowance still standing in every bucket, and refused, the pairs of charge id and reason.
 */
export function applyCharges(caps: Record<string, [number, number]>, charges: [string, string, number][]): { left: Record<string, number>; refused: [string, string][] } {
  const refused: [string, string][] = [];

  for (const charge of charges) {
    const [id, bucket, amount] = charge;

    if (!Number.isInteger(amount) || amount <= 0) {
      throw new Error('Amount must be a positive whole number');
    }

    if (!(bucket in caps)) {
      refused.push([id, 'unknown']);
      continue;
    }

    const [allowance, maxSingleCharge] = caps[bucket];

    if (amount > maxSingleCharge) {
      refused.push([id, 'single']);
      continue;
    }

    if (amount > allowance) {
      refused.push([id, 'cap']);
      continue;
    }

    caps[bucket][0] -= amount;
  }

  const left = Object.fromEntries(Object.entries(caps).map(([bucket, [allowance]]) => [bucket, allowance]));

  return { left, refused };
}
```