feat: add comprehensive logging to payment cron for debugging

Every 10 minutes, the cron now logs:
- When it triggers and completes
- Satoshi rate fetched (or failure)
- Number of eligible shareholders found
- If 0 found: warns about shareholders with revenue but mismatched criteria
- Per-shareholder: revenue, filmmaker ID, lightning address
- Payment preparation: amount in sats, destination address
- Duplicate detection
- Payment send result (completed or failed with error)
- Revenue deduction confirmation

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dorian
2026-02-13 23:07:31 +00:00
parent 0e2f2b0a73
commit 38293b1f95
2 changed files with 53 additions and 3 deletions

View File

@@ -34,6 +34,7 @@ import { SubscriptionType } from 'src/subscriptions/enums/types.enum';
@Injectable()
export class PaymentService {
private readonly logger = new Logger(PaymentService.name);
provider: LightningService;
constructor(
@@ -161,10 +162,12 @@ export class PaymentService {
@Cron(CronExpression.EVERY_10_MINUTES)
async handlePaymentsCron() {
this.logger.log('[cron] ── Payment cron triggered ──');
await Promise.all([
this.handlePayment('watch'),
this.handlePayment('rent'),
]);
this.logger.log('[cron] ── Payment cron complete ──');
}
@Cron(CronExpression.EVERY_DAY_AT_10AM)
@@ -195,17 +198,26 @@ export class PaymentService {
type: 'watch' | 'rent' = 'watch',
frequency: Frequency = 'automatic',
) {
this.logger.log(`[payout:${type}] Starting (frequency=${frequency})`);
// Rental prices (and therefore rentPendingRevenue) are denominated in
// sats, not USD. The satoshi rate is only needed for watch/subscription
// payments which store revenue in USD. Skip the rate fetch entirely for
// rent payouts so they are never blocked by rate-API outages or 429s.
let satoshiRate = 0;
if (type !== 'rent') {
satoshiRate = await this.provider.getSatoshiRate();
try {
satoshiRate = await this.provider.getSatoshiRate();
this.logger.log(`[payout:${type}] Satoshi rate: ${satoshiRate}`);
} catch (err) {
this.logger.error(`[payout:${type}] Failed to get satoshi rate: ${err.message}`);
return;
}
}
const column = type === 'watch' ? 'pendingRevenue' : 'rentPendingRevenue';
const minThreshold = type === 'rent' ? 1 : satoshiRate;
this.logger.log(`[payout:${type}] Looking for shareholders with ${column} >= ${minThreshold}`);
const options: FindManyOptions<Shareholder> = {
where: {
@@ -226,17 +238,43 @@ export class PaymentService {
};
const shareholders = await this.shareholdersRepository.find(options);
this.logger.log(`[payout:${type}] Found ${shareholders.length} eligible shareholder(s)`);
if (shareholders.length === 0) {
// Extra debug: check if there are shareholders with pending revenue at all
const allWithRevenue = await this.shareholdersRepository
.createQueryBuilder('s')
.select(['s.id', `s.${column === 'pendingRevenue' ? 'pending_revenue' : 'rent_pending_revenue'} as revenue`])
.where(`s.${column === 'pendingRevenue' ? 'pending_revenue' : 'rent_pending_revenue'} > 0`)
.limit(5)
.getRawMany();
if (allWithRevenue.length > 0) {
this.logger.warn(
`[payout:${type}] ${allWithRevenue.length} shareholder(s) have revenue > 0 but don't match payout criteria. ` +
`Possible issues: no LIGHTNING payment method, not selected, or wrong withdrawalFrequency. ` +
`Sample: ${JSON.stringify(allWithRevenue)}`,
);
}
}
for (const shareholder of shareholders) {
const revenue = type === 'watch' ? shareholder.pendingRevenue : shareholder.rentPendingRevenue;
const selectedPaymentMethod = shareholder.filmmaker.paymentMethods.find(
(method) => method.selected,
);
this.logger.log(
`[payout:${type}] Shareholder ${shareholder.id}: revenue=${revenue}, ` +
`filmmaker=${shareholder.filmmaker?.id}, ` +
`lightningAddress=${selectedPaymentMethod?.lightningAddress || 'NONE'}`,
);
if (selectedPaymentMethod?.lightningAddress)
await this.sendLightningPaymentToShareholder(
shareholder,
satoshiRate,
type,
);
else
this.logger.warn(`[payout:${type}] Skipping shareholder ${shareholder.id} — no lightning address`);
}
}
@@ -267,6 +305,7 @@ export class PaymentService {
}
if (rounded <= 0) {
this.logger.log(`[payout:${type}] Shareholder ${shareholder.id}: rounded amount = 0, skipping`);
return;
}
@@ -274,6 +313,11 @@ export class PaymentService {
(method) => method.selected && method.type === 'LIGHTNING',
);
this.logger.log(
`[payout:${type}] Preparing payment: shareholder=${shareholder.id}, ` +
`amount=${rounded} sats (${rounded * 1000} msats), address=${selectedLightningAddress?.lightningAddress}`,
);
const payment = this.paymentsRepository.create({
id: randomUUID(),
shareholderId: shareholder.id,
@@ -295,12 +339,15 @@ export class PaymentService {
});
if (existingPayment) {
this.logger.warn(`[payout:${type}] Duplicate pending payment detected for shareholder ${shareholder.id}, skipping`);
return;
} else {
await this.paymentsRepository.save(payment);
this.logger.log(`[payout:${type}] Payment record saved: id=${payment.id}`);
}
try {
this.logger.log(`[payout:${type}] Sending ${rounded} sats to ${selectedLightningAddress.lightningAddress}...`);
const providerPayment = await this.provider.sendPaymentWithAddress(
selectedLightningAddress.lightningAddress,
payment,
@@ -308,6 +355,7 @@ export class PaymentService {
payment.providerId = providerPayment.id;
payment.status = 'completed';
this.logger.log(`[payout:${type}] Payment COMPLETED: id=${payment.id}, providerId=${providerPayment.id}`);
await (type === 'watch'
? this.shareholdersRepository.update(
@@ -325,8 +373,10 @@ export class PaymentService {
updatedAt: () => 'updated_at',
},
));
} catch {
this.logger.log(`[payout:${type}] Revenue deducted from shareholder ${shareholder.id}`);
} catch (err) {
payment.status = 'failed';
this.logger.error(`[payout:${type}] Payment FAILED for shareholder ${shareholder.id}: ${err?.message || err}`);
} finally {
await this.paymentsRepository.save(payment);
}

View File

@@ -47,7 +47,7 @@ services:
context: ./backend
dockerfile: Dockerfile
args:
CACHEBUST: "12"
CACHEBUST: "13"
restart: unless-stopped
environment:
# ── Core ─────────────────────────────────────────────