Skip to content

feat: add support for controlling user subscription is billed to - #16

Open
mrbasel wants to merge 7 commits into
Genie-sa:mainfrom
mrbasel:billing-identity
Open

feat: add support for controlling user subscription is billed to#16
mrbasel wants to merge 7 commits into
Genie-sa:mainfrom
mrbasel:billing-identity

Conversation

@mrbasel

@mrbasel mrbasel commented Aug 4, 2026

Copy link
Copy Markdown

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

mrbasel and others added 7 commits August 4, 2026 18:02
Who gets billed needs no configuration: HTTP endpoints bill the session
user; the server-only endpoints bill the reference.

- organization: { enabled: true } makes org references billable: the org
  gets its own consumer, stored on a new organization.streampayConsumerId
  column (schema gated behind the option); optional
  organization.getBillingDetails supplies contact/tax fields while
  name/email/external_id stay plugin-owned
- new server-only auth.api.upgradeSubscriptionForReference: trusted server
  code starts a subscription checkout billed to any user or organization —
  no session, caller is the gate, every validation still runs
- new server-only auth.api.checkoutForReference: one-time payment link
  billed to any user or organization, with validated referenceId/
  referenceType attribution in link metadata; HTTP checkout unchanged
- org consumers pinned to external_id ref:organization:<orgId>; user
  external ids unchanged (bare user.id)
- typed errors: SUBSCRIPTION_ORG_BILLING_NOT_ENABLED, ORG_NOT_FOUND,
  BILLING_CONTACT_REQUIRED
- requires better-auth >= 1.6.23 (createAuthEndpoint.serverOnly)

Co-authored-by: Basel <baselalnassr8@gmail.com>
…ints

Co-authored-by: Basel <baselalnassr8@gmail.com>
- Pending subscription checkouts only resume for the same StreamPay
  consumer; a link created for one payer is never handed to another.
- Organization upgrades grant no trial when isTrialEligible is
  configured (user-scoped policy fails closed for orgs).
- checkoutForReference allows one payment by default, passes the
  resolved user and referenceType to onCheckoutCreated.
- Organization consumer claims are compare-and-set; concurrent first
  checkouts settle on one consumer.
- organization.modelName preserves custom org table names; startup
  fails fast when the organization plugin is missing or mismatched.
- Drop accidental pnpm-workspace.yaml; CI minimum-peer job now
  installs better-auth 1.6.23.

Co-authored-by: Basel <baselalnassr8@gmail.com>
- A StreamPay consumer linked to an organization can never be claimed
  by a user, and the other way around; the user recovery and duplicate-
  claim paths now check the organization table when org billing is on,
  before any external_id rewrite.
- Webhook reconciliation verifies the payer: a provider subscription
  billed to a different consumer than the local row dead-letters the
  protected correlation and is never matched by the fallback.
- A consumer created by the losing side of a concurrent organization
  claim is deleted best-effort.
- organization.getBillingDetails may supply a billing email; name and
  external_id stay plugin-owned.
- Export BillingOrganization, OrganizationBillingOptions, and
  OrganizationConsumerOverrides from the package root; internalize
  helpers only used inside billing-account.ts; drop the unused
  BillingAccountResolution.referenceId field.
- Real-router integration test: both server-only endpoints exist on
  auth.api and no guessed HTTP route serves them.

Co-authored-by: Basel <baselalnassr8@gmail.com>
- Known-subscription webhooks dead-letter when the provider consumer
  differs from the stored payer; a provider payload without a consumer
  never clears the stored payer (existing-ID, protected, and fallback
  paths all covered).
- Signup consumer adoption (createConsumerOnSignUp +
  claimExistingConsumerBy) enforces the same ownership rules as lazy
  provisioning, and a consumer whose external_id carries the
  organization prefix is never claimable by a user at all.
- The organization ownership check also runs when org billing is
  currently disabled, so retained organization links stay protected
  after a feature rollback.
- The losing side of an organization claim re-checks ownership right
  before deleting its unused consumer, and a failed persistence also
  cleans up the consumer it created.
- Organization billing always requires the Better Auth organization
  plugin; model names are normalized before comparison, so a custom
  name on either side without the other fails fast at startup.
- BILLING_CONTACT_REQUIRED is raised only when StreamPay's field-level
  validation names email or phone_number; other provider validation
  errors pass through unchanged.
- Peer floor corrected to better-auth ^1.6.17 (verified: serverOnly
  ships in 1.6.17, absent in 1.6.16) across package.json, CI, README,
  and the skill; the changeset is now major because dropping the 1.5
  peer range is a breaking release.

Co-authored-by: Basel <baselalnassr8@gmail.com>
…g rollback policy

- Regenerate pnpm-lock.yaml so `pnpm install --frozen-lockfile` passes from
  a clean checkout (specifier was stale after the ^1.6.17 peer change); pin
  the toolchain with `packageManager: pnpm@10.13.1` and let CI read it.
- INVOICE_COMPLETED now runs the same payer assertion as SUBSCRIPTION_*
  events: a provider consumer that differs from the stored payer
  dead-letters the event before any projection or renewal callback.
- Fallback correlation refuses to create a replacement row when matching
  reference/plan candidates exist but are all billed to a different
  consumer — independent of subscription status or active-slot uniqueness,
  closing the terminal-status bypass.
- Organization rollback policy is now real: the org consumer field is
  registered whenever the `organization` option is present, so
  `enabled: false` keeps retained org-owned consumers protected; ownership
  queries fail closed instead of converting errors into "unowned".
- Remove synchronous best-effort consumer deletion: no ownership read can
  authorize a later provider delete race-free. Unlinked consumers are left
  in place and re-adopted via their deterministic external id.
- auth.api endpoint types now derive from the configured `use` tuple;
  unconfigured methods fail at compile time instead of being undefined at
  runtime.
- README no longer promises atomic cross-model ownership; changeset and
  docs describe the rollback mode and the remaining same-instant race.

Co-authored-by: Basel <baselalnassr8@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants