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

View File

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