Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/payments/entities/payment.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export enum PaymentStatus {
FAILED = 'failed',
EXPIRED = 'expired',
REFUNDED = 'refunded',
PARTIALLY_REFUNDED = 'partially_refunded',
}

export enum PaymentNetwork {
Expand Down
87 changes: 50 additions & 37 deletions src/payments/payments.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DataSource, Repository } from 'typeorm';
import { v4 as uuidv4 } from 'uuid';
import * as QRCode from 'qrcode';
import * as StellarSdk from '@stellar/stellar-sdk';
Expand Down Expand Up @@ -40,6 +40,7 @@ export class PaymentsService {
private merchants: MerchantsService,
private soroban: SorobanService,
private analytics: AnalyticsService,
private dataSource: DataSource,
) {}

async create(merchantId: string, dto: CreatePaymentDto): Promise<Payment> {
Expand Down Expand Up @@ -223,8 +224,14 @@ export class PaymentsService {
});
}

// ── Persist all records in one shot (atomic) ──────────────────────────────
const saved = await this.paymentsRepo.save(records);
// ── Persist all records inside one DB transaction. A plain array save()
// is not guaranteed atomic against constraint violations that only
// manifest at insert time (e.g. a unique-constraint collision) or a
// connection loss mid-batch — wrapping in a transaction backs the
// "no partial writes" contract documented above with the database. ──
const saved = await this.dataSource.transaction(async (manager) => {
return manager.save(records);
});

// ── Emit PaymentCreated event for each entry (mirrors contract event log) ─
for (const event of events) {
Expand Down Expand Up @@ -276,23 +283,11 @@ export class PaymentsService {
}

async refund(id: string, merchantId: string, dto: RefundPaymentDto): Promise<Payment> {
const payment = await this.findOne(id, merchantId);

if (payment.status !== PaymentStatus.SETTLED) {
throw new BadRequestException('Only settled payments can be refunded');
}

if (!payment.customerWalletAddress) {
throw new BadRequestException('Customer wallet address is unknown. Manual refund required.');
}

const merchant = await this.merchants.findOne(merchantId);

// Determine refund amount
const refundAmountUsd = dto.amountUsd || payment.amountUsd;
if (refundAmountUsd > payment.amountUsd) {
throw new BadRequestException('Refund amount cannot exceed original payment amount');
}
let payment: Payment;
let refundAmountUsd: number;
let txHash: string;

// Determine asset and amount for Stellar transfer
let asset: StellarSdk.Asset;
Expand All @@ -310,34 +305,50 @@ export class PaymentsService {
amountStr = amountXlm.toFixed(7);
}

const memo = `REFUND-${payment.reference.split('-').pop()}`;
const totalRefundedUsd = alreadyRefundedUsd + refundAmountUsd;
payment.status =
totalRefundedUsd >= payment.amountUsd
? PaymentStatus.REFUNDED
: PaymentStatus.PARTIALLY_REFUNDED;
payment.refundAmountUsd = totalRefundedUsd;
payment.refundReason = dto.reason;
payment.refundTxHash = txHash;
payment.refundedAt = new Date();

try {
const txHash = await this.stellar.sendPayment(
payment.customerWalletAddress,
amountStr,
asset,
memo,
);
const saved = await manager.save(payment);

payment.status = PaymentStatus.REFUNDED;
payment.refundAmountUsd = refundAmountUsd;
payment.refundReason = dto.reason;
payment.refundTxHash = txHash;
payment.refundedAt = new Date();
return { saved, refundAmountUsd, txHash };
});

const saved = await this.paymentsRepo.save(payment);
payment = result.saved;
refundAmountUsd = result.refundAmountUsd;
txHash = result.txHash;
} catch (err) {
if (err instanceof BadRequestException || err instanceof NotFoundException) {
throw err;
}
throw new BadRequestException(`Stellar refund failed: ${err.message}`);
}

// Dispatch webhook
// Step 2: webhook + email dispatch happens *after* the financial action
// has already succeeded and been saved. Failures here (e.g. a webhook
// queue error) must not be reported as a refund failure — the funds have
// genuinely moved. Log and continue on a best-effort basis instead.
try {
await this.webhooks.dispatch(merchantId, 'payment.refunded', {
paymentId: payment.id,
reference: payment.reference,
refundAmountUsd,
refundTxHash: txHash,
reason: dto.reason,
});
} catch (err) {
this.logger.error(
`Refund webhook dispatch failed for payment ${payment.id} (refund already completed): ${err.message}`,
);
}

// Send emails
try {
await this.notifications.enqueueEmail({
recipient: merchant.email,
subject: `Refund processed: ${payment.reference}`,
Expand All @@ -351,10 +362,12 @@ export class PaymentsService {
html: `<p>A refund of $${refundAmountUsd} has been processed for your payment ${payment.reference}.</p><p>The funds have been sent back to your Stellar wallet.</p>`,
});
}

return saved;
} catch (err) {
throw new BadRequestException(`Stellar refund failed: ${err.message}`);
this.logger.error(
`Refund notification email failed for payment ${payment.id} (refund already completed): ${err.message}`,
);
}

return payment;
}
}