Skip to content

feat(billing): credit managed wallet from manual-credit Stripe invoices#3398

Open
baktun14 wants to merge 4 commits into
mainfrom
feat/billing-manual-credit-webhook-crediting
Open

feat(billing): credit managed wallet from manual-credit Stripe invoices#3398
baktun14 wants to merge 4 commits into
mainfrom
feat/billing-manual-credit-webhook-crediting

Conversation

@baktun14

@baktun14 baktun14 commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Why

Part of CON-625.

An operator needs to grant spendable credit to a customer's managed wallet (e.g. enterprise GPU prepay via Brex ACH) without the customer paying or redeeming a coupon. The finance-approved mechanism is a comped Stripe invoice (marked paid out-of-band) that produces a paper trail, whose invoice.paid webhook then credits the wallet on-chain.

This PR is the apps/api crediting slice, and it reuses the existing coupon-claim mechanism rather than inventing a new one: the admin app (CON-626, ovrclk/console-private) pre-creates the pending stripe_transactions row — it has direct DB access, exactly as the coupon endpoint pre-creates its row internally — and marks the invoice paid out-of-band. apps/api's webhook then credits the matching row through the existing idempotent path. No new endpoint, no metadata parsing, no bespoke crediting method in apps/api.

Non-closing keyword on purpose: CON-625 still lists the endpoint/caps/ledger ACs that belong to CON-626 / a caps+ledger follow-up. Trim CON-625 to this crediting scope once those move.

What

  • Add manual_credit to the stripe_transaction_type enum.
  • Add a partial unique index on stripe_invoice_id (WHERE NOT NULL): enforces one row per invoice (guards both the coupon and manual-credit paths) and indexes the previously-unindexed findByInvoiceId lookup.
  • Subscribe the webhook to invoice.paid alongside invoice.payment_succeeded (out-of-band-paid invoices fire invoice.paid).
  • Treat manual_credit like coupon_claim for endTrial — a granted credit must not graduate a trial user.
  • Crediting, exactly-once, and the double-credit guard all come from the existing updateTransactionAndTopUp: it locks the matched row and no-ops if already succeeded; an invoice with no matching row no-ops.

The net apps/api change is small — enum value + index + migration + the invoice.paid subscription + the endTrial tweak, plus tests. (The PR's first commit implemented a metadata-driven variant; the second reverts that in favor of reusing the coupon path.)

Tests: unit (matched-row manual_credit credits with endTrial:false; invoice.paid / invoice.payment_succeeded routing), real-DB integration (partial-unique-index invariant — duplicate stripe_invoice_id rejected, nulls allowed), functional (HTTP end-to-end — single credit keeps the user trialing; invoice.paid + invoice.payment_succeeded double-fire credits once; an unmatched invoice creates no row and no credit).

Migration — 0030_true_jetstream.sql

ALTER TYPE "stripe_transaction_type" ADD VALUE 'manual_credit' + CREATE UNIQUE INDEX ... WHERE stripe_invoice_id IS NOT NULL.

  • Runs cleanly on the project's PostgreSQL 14: the new enum value is not used in the same transaction, so ADD VALUE is fine inside drizzle's single migration transaction.
  • The index is additive and non-blocking; only the coupon path writes stripe_invoice_id today (one row per invoice). Pre-deploy check: confirm there are no existing duplicate stripe_invoice_id values in prod.

Cross-repo contract (for CON-626, admin app)

Using the same DB and the same Stripe account/keys, the admin app must:

  1. Insert a stripe_transactions row { userId, type:"manual_credit", status:"pending", amount (cents), currency, stripeInvoiceId, description } before marking the invoice paid — otherwise the invoice.paid webhook can fire first, find no row, and no-op. Handle the stripe_invoice_id unique index on insert (e.g. ON CONFLICT DO NOTHING).
  2. Create + finalize the invoice and mark it paid out-of-band.
  3. Reject users without a stripeCustomerId.

Deploy ordering: this migration (0030, which adds the manual_credit enum value) must ship before the admin app writes manual_credit rows, or those inserts fail.

Coupling note: two services now write stripe_transactions, so that table's shape becomes a shared contract. If that coupling becomes painful, revisit whether the admin app should instead go through an apps/api endpoint (the original CON-626 direction).

Summary by CodeRabbit

  • New Features
    • Added support for manual_credit Stripe transactions for invoice-driven wallet top-ups.
    • Extended webhook routing to handle both invoice.paid (manual credits) and invoice.payment_succeeded (charged invoices).
  • Bug Fixes
    • Enforced uniqueness for Stripe transactions when an invoice ID is present (duplicates rejected; multiple NULL invoice IDs allowed).
    • Ensured invoice-based wallet crediting is idempotent across repeated/overlapping webhook events.
    • Updated trial graduation logic for credited transaction types to use correct endTrial behavior.
  • Tests
    • Added integration and functional coverage for manual credits, event routing, uniqueness, and idempotency.

Teach the Stripe webhook to recognize comped "manual_credit" invoices (created
out-of-band by the admin app with metadata {type:manual_credit,userId,reason})
and turn invoice.paid / invoice.payment_succeeded into a real, spendable
managed-wallet credit. There is no pre-created transaction row for these, so the
row is inserted keyed on the invoice id.

A partial unique index on stripe_invoice_id (WHERE NOT NULL) makes crediting
exactly-once across duplicate/retried and double-fire deliveries (invoice.paid +
invoice.payment_succeeded). The credit uses endTrial:false so a granted credit
never graduates a trial user. Unmatched non-manual-credit invoices keep
no-opping (double-credit guard).

Covered by unit, real-DB integration (dedupe), and functional (HTTP end-to-end)
tests.

Part of CON-625
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1db9dffd-d5e9-43f4-8135-314f743e020a

📥 Commits

Reviewing files that changed from the base of the PR and between c79d245 and 4a529b5.

📒 Files selected for processing (1)
  • apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.integration.ts

📝 Walkthrough

Walkthrough

This PR adds a manual_credit Stripe transaction type, enforces uniqueness for non-null stripeInvoiceId, and extends the invoice webhook flow to process invoice.paid manual-credit events. It also updates the matching schema metadata and tests.

Changes

Manual credit invoice support

Layer / File(s) Summary
Schema and migration for manual_credit type and invoice uniqueness
apps/api/src/billing/model-schemas/stripe-transaction/stripe-transaction.schema.ts, apps/api/drizzle/0030_true_jetstream.sql, apps/api/drizzle/meta/0030_snapshot.json, apps/api/drizzle/meta/_journal.json
Adds manual_credit to stripeTransactionTypeEnum, adds a partial unique index on stripeInvoiceId where not null, and records the corresponding migration metadata.
Repository test coverage for partial unique index
apps/api/src/billing/repositories/stripe-transaction/stripe-transaction.repository.integration.ts
Adds integration tests confirming duplicate non-null stripeInvoiceId values throw on create, while null values allow multiple rows.
Webhook manual-credit crediting flow
apps/api/src/billing/repositories/stripe-transaction/stripe-transaction.repository.ts, apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.ts, apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.integration.ts, apps/api/test/functional/stripe-webhook.spec.ts
Defines granted-credit transaction types, routes invoice.paid into the same invoice top-up flow, widens the handler’s event type, changes trial graduation behavior for granted-credit rows, and adds integration and functional tests for manual-credit invoice processing.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested reviewers: ygrishajev, stalniy

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/billing-manual-credit-webhook-crediting

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 70.62%. Comparing base (e77c355) to head (4a529b5).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3398      +/-   ##
==========================================
- Coverage   71.69%   70.62%   -1.08%     
==========================================
  Files        1149     1059      -90     
  Lines       28974    26643    -2331     
  Branches     7105     6648     -457     
==========================================
- Hits        20774    18816    -1958     
+ Misses       7203     6863     -340     
+ Partials      997      964      -33     
Flag Coverage Δ *Carryforward flag
api 85.65% <100.00%> (+0.05%) ⬆️
deploy-web 59.89% <ø> (ø) Carriedforward from e6373d2
log-collector ?
notifications 91.44% <ø> (ø) Carriedforward from e6373d2
provider-console 81.38% <ø> (ø) Carriedforward from e6373d2
provider-inventory ?
provider-proxy 86.42% <ø> (ø) Carriedforward from e6373d2
tx-signer ?

*This pull request uses carry forward flags. Click here to find out more.

Files with missing lines Coverage Δ
...as/stripe-transaction/stripe-transaction.schema.ts 83.33% <100.00%> (ø)
...tripe-transaction/stripe-transaction.repository.ts 42.55% <100.00%> (+7.77%) ⬆️
.../services/stripe-webhook/stripe-webhook.service.ts 79.19% <100.00%> (+1.86%) ⬆️

... and 91 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/drizzle/0030_true_jetstream.sql`:
- Around line 1-2: The migration combines an enum alteration with a blocking
unique index build, so split the `CREATE UNIQUE INDEX` on `stripe_transactions`
out of `0030_true_jetstream.sql` into a separate non-transactional migration.
Keep the `ALTER TYPE` in the transactional migration, and add the index in a
follow-up migration that runs with `CONCURRENTLY` and outside Drizzle’s
transaction flow. Use the existing
`stripe_transactions_stripe_invoice_id_unique` index name to place the change
correctly.

In
`@apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.integration.ts`:
- Around line 335-378: The webhook flow currently keeps
`refillService.topUpWallet(...)` inside the `@WithTransaction()` path in
`StripeWebhookService.tryToTopUpWalletFromInvoice`, even though it triggers
external authz/funding calls through
`ManagedUserWalletService.authorizeSpending()`. Refactor the method so the DB
transaction only covers invoice/transaction persistence, then invoke
`topUpWallet()` after the transaction has completed; update any affected
expectations in the `tryToTopUpWalletFromInvoice` tests to reflect the same
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b2a10255-ec06-4987-a356-ab322f2dc195

📥 Commits

Reviewing files that changed from the base of the PR and between 993e7c1 and db893aa.

📒 Files selected for processing (9)
  • apps/api/drizzle/0030_true_jetstream.sql
  • apps/api/drizzle/meta/0030_snapshot.json
  • apps/api/drizzle/meta/_journal.json
  • apps/api/src/billing/model-schemas/stripe-transaction/stripe-transaction.schema.ts
  • apps/api/src/billing/repositories/stripe-transaction/stripe-transaction.repository.integration.ts
  • apps/api/src/billing/repositories/stripe-transaction/stripe-transaction.repository.ts
  • apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.integration.ts
  • apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.ts
  • apps/api/test/functional/stripe-webhook.spec.ts

Comment thread apps/api/drizzle/0030_true_jetstream.sql
…oice metadata

Switch the manual-credit crediting path to reuse the existing coupon-claim
mechanism: the admin app (CON-626) pre-creates the pending stripe_transactions
row via direct DB write, and apps/api credits the matching row through the
existing updateTransactionAndTopUp path. Drops the metadata-driven webhook
branch, creditManualCreditInvoice, and insertIfInvoiceAbsent.

Keeps the manual_credit enum value, the partial unique index on
stripe_invoice_id (now guarding one row per invoice for both paths), the
invoice.paid subscription, and the endTrial handling. Exactly-once and the
double-credit guard now come entirely from the existing row lock + already
succeeded check. Tests updated: matched-row unit test, unique-index integration
test, and pre-created-row functional tests.

Part of CON-625

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.integration.ts (1)

335-366: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider a dual-delivery idempotency test.

Coverage here proves a single invoice.paid call credits correctly, but the interesting new risk is invoice.paid and invoice.payment_succeeded both firing for the same manual_credit invoice. A test calling tryToTopUpWalletFromInvoice twice (once per event variant) against the same transaction, asserting refillService.topUpWallet fires only once, would directly validate the idempotency guard this PR relies on.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.integration.ts`
around lines 335 - 366, Add a dual-delivery idempotency test for Stripe manual
credits: in the stripe-webhook.service.integration spec, exercise
tryToTopUpWalletFromInvoice twice for the same manual_credit transaction using
both invoice.paid and invoice.payment_succeeded event variants. Reuse the same
mocked transaction from setup()/createMockTransaction and assert
refillService.topUpWallet is invoked only once while the transaction is marked
succeeded via stripeTransactionRepository.updateById.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.integration.ts`:
- Around line 335-366: Add a dual-delivery idempotency test for Stripe manual
credits: in the stripe-webhook.service.integration spec, exercise
tryToTopUpWalletFromInvoice twice for the same manual_credit transaction using
both invoice.paid and invoice.payment_succeeded event variants. Reuse the same
mocked transaction from setup()/createMockTransaction and assert
refillService.topUpWallet is invoked only once while the transaction is marked
succeeded via stripeTransactionRepository.updateById.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c33391b9-9b49-434f-b730-4dea912ff683

📥 Commits

Reviewing files that changed from the base of the PR and between db893aa and e6373d2.

📒 Files selected for processing (4)
  • apps/api/src/billing/repositories/stripe-transaction/stripe-transaction.repository.integration.ts
  • apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.integration.ts
  • apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.ts
  • apps/api/test/functional/stripe-webhook.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/api/test/functional/stripe-webhook.spec.ts

baktun14 added 2 commits July 8, 2026 19:29
Replace the inline ["coupon_claim","manual_credit"].includes(...) check with a
type-safe GRANTED_CREDIT_TRANSACTION_TYPES set co-located with StripeTransactionType,
so new credit-grant enum values live in one discoverable place.

Clarify the invoice partial-unique-index comment: it enforces one row per invoice;
exactly-once crediting comes from the locked status check in the webhook.

Simplify integration-test user creation to create({}); consolidate the two duplicate
routeStripeEvent tests via it.each; drop the never-passed amountPaid payload param.
@baktun14

baktun14 commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the review feedback:

  • Dual-delivery idempotency (nitpick): added a test in stripe-webhook.service.integration.ts that fires both invoice.paid and invoice.payment_succeeded for the same manual_credit invoice and asserts refillService.topUpWallet runs exactly once (the already-succeeded lock guard no-ops the second delivery).
  • Migration index (major): CONCURRENTLY isn't viable here — the drizzle postgres-js migrator wraps migrations in a transaction, which Postgres forbids for CONCURRENTLY. Details + resolution in the inline thread; keeping the plain partial unique index in line with the repo's established pattern.
  • topUpWallet inside transaction (major): pre-existing updateTransactionAndTopUp path, not introduced by this PR; already resolved/outdated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant