import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { DatabaseService } from '../../../infrastructure/database/database.service';
import { SaaSBillingProviderRegistry } from '../providers/saas-billing-provider.registry';
import { EntitlementProjectionService } from '../application/entitlement-projection.service';
import { BillingHistoryService } from '../application/billing-history.service';
@Injectable()
export class BillingReconciliationWorker {
constructor(
private readonly database:DatabaseService,
private readonly providers:SaaSBillingProviderRegistry,
private readonly entitlements:EntitlementProjectionService,
private readonly history:BillingHistoryService,
) {}
async reconcileOrganization(organizationId:string) {
const account=(await this.database.query<any>(
`select * from billing_accounts where organization_id=$1`,
[organizationId],
))[0];
if(!account) return {status:'no_billing_account'};
const runId=randomUUID();
await this.database.query(
`insert into commercial_reconciliation_runs(id,organization_id,billing_account_id)
values($1,$2,$3)`,
[runId,organizationId,account.id],
);
try {
const sub=(await this.database.query<any>(
`select * from organization_subscriptions
where organization_id=$1 order by created_at desc limit 1`,
[organizationId],
))[0];
if(sub?.provider_subscription_id) {
const provider=this.providers.get(account.provider);
const p:any=await provider.getSubscription({
organizationId,billingAccountId:account.id,providerCustomerId:account.provider_customer_id,
credentialReference:String(process.env.SAAS_BILLING_CREDENTIAL_REFERENCE??'env:STRIPE_SECRET_KEY'),
config:{},
},{providerSubscriptionId:sub.provider_subscription_id});
await this.database.query(
`update organization_subscriptions
set provider_status=$1,last_reconciled_at=now(),reconciliation_error=null,
current_period_start=coalesce($2,current_period_start),
current_period_end=coalesce($3,current_period_end),
cancel_at_period_end=coalesce($4,cancel_at_period_end),
updated_at=now(),version=version+1
where id=$5`,
[
String(p.status??sub.provider_status),
p.current_period_start?new Date(Number(p.current_period_start)*1000).toISOString():null,
p.current_period_end?new Date(Number(p.current_period_end)*1000).toISOString():null,
p.cancel_at_period_end==null?null:Boolean(p.cancel_at_period_end),sub.id,
],
);
}
const invoices=await this.history.refresh(organizationId);
await this.database.query(
`update billing_accounts set last_reconciled_at=now(),reconciliation_error=null where id=$1`,
[account.id],
);
await this.entitlements.rebuild(organizationId);
await this.database.query(
`update commercial_reconciliation_runs
set status='completed',completed_at=now(),details_safe_json=$1::jsonb where id=$2`,
[JSON.stringify({invoicesSynced:invoices.synced}),runId],
);
return {status:'completed',invoicesSynced:invoices.synced};
} catch(error:any) {
const message=String(error?.message??error).slice(0,1000);
await this.database.query(
`update billing_accounts set reconciliation_error=$1 where id=$2`,
[message,account.id],
);
await this.database.query(
`update commercial_reconciliation_runs
set status='failed',completed_at=now(),last_error=$1 where id=$2`,
[message,runId],
);
// Crucial: no entitlement projection deletion/revocation on provider outage.
return {status:'failed'};
}
}
}
