@@ -36,12 +36,18 @@ import {
3636import { WinningPaymentDetailsDto } from './dto/payment-details.dto' ;
3737
3838const PAYMENT_DECIMAL_PLACES = 2 ;
39+ const BUDGET_LEDGER_DECIMAL_PLACES = 4 ;
3940
4041interface ChallengeBudgetSyncTarget {
4142 billingAccountId : number ;
4243 challengeId : string ;
4344}
4445
46+ interface EngagementBudgetSyncTarget {
47+ assignmentId : string ;
48+ billingAccountId : number ;
49+ }
50+
4551/**
4652 * The admin winning service.
4753 */
@@ -394,6 +400,55 @@ export class AdminService {
394400 return [ ...targets . values ( ) ] ;
395401 }
396402
403+ /**
404+ * Finds engagement billing-account rows that need to be reconciled after a
405+ * wallet-admin payment status change.
406+ *
407+ * @param payments payment rows selected by the update request.
408+ * @returns unique engagement assignment and billing-account pairs touched by
409+ * USD engagement payments.
410+ * @throws This helper does not throw.
411+ */
412+ private getEngagementBudgetSyncTargets (
413+ payments : Awaited < ReturnType < AdminService [ 'getPaymentsByWinningsId' ] > > ,
414+ ) : EngagementBudgetSyncTarget [ ] {
415+ const targets = new Map < string , EngagementBudgetSyncTarget > ( ) ;
416+
417+ payments . forEach ( ( payment ) => {
418+ const assignmentId =
419+ typeof payment . winnings . external_id === 'string'
420+ ? payment . winnings . external_id . trim ( )
421+ : '' ;
422+
423+ if (
424+ ! assignmentId ||
425+ payment . winnings . type !== winnings_type . PAYMENT ||
426+ payment . winnings . category !== winnings_category . ENGAGEMENT_PAYMENT ||
427+ ( payment . currency ?? '' ) !== 'USD'
428+ ) {
429+ return ;
430+ }
431+
432+ const billingAccountId = this . normalizeBillingAccountId (
433+ payment . billing_account ,
434+ ) ;
435+
436+ if ( ! billingAccountId ) {
437+ this . logger . warn (
438+ `Skipping engagement budget sync for payment ${ payment . payment_id } ; invalid billing account ${ String ( payment . billing_account ) } ` ,
439+ ) ;
440+ return ;
441+ }
442+
443+ targets . set ( `${ assignmentId } :${ billingAccountId } ` , {
444+ assignmentId,
445+ billingAccountId,
446+ } ) ;
447+ } ) ;
448+
449+ return [ ...targets . values ( ) ] ;
450+ }
451+
397452 /**
398453 * Resolves the billing account to synchronize for a challenge.
399454 *
@@ -465,6 +520,25 @@ export class AdminService {
465520 ) ;
466521 }
467522
523+ /**
524+ * Quantizes a billing-account ledger amount to the same four-decimal scale
525+ * used by billing-accounts-api-v6.
526+ *
527+ * @param amount decimal amount to normalize.
528+ * @returns JavaScript number rounded to four decimal places.
529+ * @throws This helper does not throw.
530+ */
531+ private toBillingLedgerAmount ( amount : Prisma . Decimal ) : number {
532+ return Number (
533+ amount
534+ . toDecimalPlaces (
535+ BUDGET_LEDGER_DECIMAL_PLACES ,
536+ Prisma . Decimal . ROUND_HALF_UP ,
537+ )
538+ . toFixed ( BUDGET_LEDGER_DECIMAL_PLACES ) ,
539+ ) ;
540+ }
541+
468542 /**
469543 * Sums non-cancelled USD payment rows for a challenge and billing account.
470544 *
@@ -547,6 +621,74 @@ export class AdminService {
547621 ) ;
548622 }
549623
624+ /**
625+ * Reads active engagement payment ledger amounts for one assignment.
626+ *
627+ * @param assignmentId engagement assignment external id stored on winnings.
628+ * @param billingAccountId billing account stored on payment rows.
629+ * @returns ledger-scale consumed amounts for non-cancelled finance payments,
630+ * in the same order used when engagement consumed rows were created.
631+ * @throws Prisma errors when the payment query fails.
632+ */
633+ private async getActiveEngagementConsumeAmounts (
634+ assignmentId : string ,
635+ billingAccountId : number ,
636+ ) : Promise < number [ ] > {
637+ const payments = await this . prisma . payment . findMany ( {
638+ select : {
639+ challenge_fee : true ,
640+ total_amount : true ,
641+ } ,
642+ where : {
643+ billing_account : String ( billingAccountId ) ,
644+ currency : PrizeType . USD ,
645+ payment_status : { not : payment_status . CANCELLED } ,
646+ winnings : {
647+ category : winnings_category . ENGAGEMENT_PAYMENT ,
648+ external_id : assignmentId ,
649+ type : winnings_type . PAYMENT ,
650+ } ,
651+ } ,
652+ orderBy : [ { created_at : 'asc' } , { payment_id : 'asc' } ] ,
653+ } ) ;
654+
655+ return payments . map ( ( paymentRow ) =>
656+ this . toBillingLedgerAmount (
657+ new Prisma . Decimal ( paymentRow . total_amount ?? 0 ) . plus (
658+ new Prisma . Decimal ( paymentRow . challenge_fee ?? 0 ) ,
659+ ) ,
660+ ) ,
661+ ) ;
662+ }
663+
664+ /**
665+ * Reconciles engagement billing-account consumed rows after a wallet-admin
666+ * cancellation changes the active payment set.
667+ *
668+ * @param targets unique engagement assignment and billing-account pairs to
669+ * synchronize.
670+ * @returns promise resolved after every target has been reconciled in BA.
671+ * @throws Error when BA synchronization fails.
672+ */
673+ private async syncEngagementBudgetTargets (
674+ targets : EngagementBudgetSyncTarget [ ] ,
675+ ) : Promise < void > {
676+ await Promise . all (
677+ targets . map ( async ( target ) => {
678+ const amounts = await this . getActiveEngagementConsumeAmounts (
679+ target . assignmentId ,
680+ target . billingAccountId ,
681+ ) ;
682+
683+ await this . baService . syncEngagementConsumeAmounts ( {
684+ amounts,
685+ billingAccountId : target . billingAccountId ,
686+ externalId : target . assignmentId ,
687+ } ) ;
688+ } ) ,
689+ ) ;
690+ }
691+
550692 /**
551693 * Verify that a BA admin user has access to the billing account(s)
552694 * associated with the given winningsId. Throws BadRequestException when
@@ -642,6 +784,10 @@ export class AdminService {
642784 body . paymentStatus === PaymentStatus . CANCELLED
643785 ? this . getChallengeBudgetSyncTargets ( payments )
644786 : [ ] ;
787+ const engagementBudgetSyncTargets =
788+ body . paymentStatus === PaymentStatus . CANCELLED
789+ ? this . getEngagementBudgetSyncTargets ( payments )
790+ : [ ] ;
645791
646792 // iterate payments and build transaction list
647793 payments . forEach ( ( payment ) => {
@@ -928,6 +1074,10 @@ export class AdminService {
9281074 await this . syncChallengeBudgetTargets ( challengeBudgetSyncTargets ) ;
9291075 }
9301076
1077+ if ( engagementBudgetSyncTargets . length > 0 ) {
1078+ await this . syncEngagementBudgetTargets ( engagementBudgetSyncTargets ) ;
1079+ }
1080+
9311081 if ( needsReconciliation ) {
9321082 const winning = await this . prisma . winnings . findFirst ( {
9331083 select : {
0 commit comments