Push billable usage to Stripe via Supabase meter-payg-units edge function after the
ledger DEBIT durably commits. Closes the "we tracked it" → "Stripe knows" loop.
PaygMeterReportingService POSTs {team_id, stripe_customer_id, units, idempotency_key,
metadata.category} to a config-driven endpoint. Both payg.meter.endpoint and
payg.meter.service-role-token default empty → no-op in local dev / tests. Never
throws; non-2xx and exceptions bump payg.meter.errors counter and log WARN.
JobChargeService.close() delegates to JobService.close() and registers an
afterCommit synchronization that fires the meter event for paid teams. Skips
when: shadow row is REFUNDED, category is BYPASSED, team has no stripeCustomerId
(free tier — ledger entry suffices), or no synchronization is active (defensive).
Idempotency key: "process:<jobId>:close" — deterministic across pod retries and
the reconciliation backfill so Stripe never bills twice for one close.
Tests:
- PaygMeterReportingServiceTest: happy path body+headers, 5xx, ConnectException,
RuntimeException, blank/null endpoint, zero units, blank service-role-token.
- JobChargeServiceTest extended: subscribed posts; free-tier skips; refunded skips;
bypassed skips; no shadow row skips; no team-ext row skips; meter exception is
swallowed inside afterCommit; close() without an active transaction skips the
meter post but still closes the job.
Free-tier detection uses stripeCustomerId == null as the established pre-#6532
stand-in (see PaygWalletController for the same pattern). Tightens to
paygSubscriptionId != null when PR #6532's payg_team_extensions column lands.