Skip to content

feat: add sendPaddlePurchase#96

Merged
nikita-ushakov merged 4 commits into
developfrom
nikita/dev-937-paddle-purchase-sdk
Jun 2, 2026
Merged

feat: add sendPaddlePurchase#96
nikita-ushakov merged 4 commits into
developfrom
nikita/dev-937-paddle-purchase-sdk

Conversation

@nikita-ushakov

@nikita-ushakov nikita-ushakov commented May 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds sendPaddlePurchase as the Paddle counterpart to sendStripePurchase. After a checkout completes in Paddle.js, call this method with the transaction_id (and subscription_id for subscriptions) and the backend validates the purchase and grants entitlements before the promise resolves.

await Qonversion.shared.sendPaddlePurchase({
  price,
  currency,
  purchased,
  transactionId,
  customerId,
  productId,
  type,         // 'subscription' | 'inapp'
  subscriptionId, // required when type === 'subscription'; omit for 'inapp'
});

Wire flow

  • QonversionInstance.sendPaddlePurchase
  • QonversionInternal.sendPaddlePurchase
  • PurchasesController.sendPaddlePurchase (logger + user id lookup, mirrors the stripe path)
  • PurchasesService.sendPaddlePurchase
  • RequestConfigurator.configurePaddlePurchaseRequestPOST /v3/users/{userId}/purchases with body:
    {
      "price": "...",
      "currency": "...",
      "purchased": ...,
      "paddle_store_data": {
        "transaction_id": "txn_...",
        "customer_id": "ctm_...",
        "product_id": "pro_...",
        "type": "subscription",
        "subscription_id": "sub_..."
      }
    }
    For one-time (type: 'inapp') purchases subscription_id is omitted from the body entirely.

Notes

  • UserPurchase.stripeStoreData and UserPurchase.paddleStoreData are now both optional — a purchase row carries exactly one.
  • PurchaseService grew a small private executePurchaseRequest helper to dedupe the success-or-throw shape between Stripe and Paddle.

Test plan

  • yarn test → 220/220 (Jest)
  • yarn tsc --noEmit clean
  • New unit tests:
    • PurchasesService.test.ts: paddle success + paddle error
    • PurchasesController.test.ts: paddle success + paddle error
    • QonversionInternal.test.ts: sendPaddlePurchase delegation + log line
    • RequestConfigurator.test.ts: paddle subscription request body + paddle inapp request body (asserts subscription_id is omitted)

Surfaces a Paddle-aware counterpart to sendStripePurchase. Same shape,
same endpoint (POST /v3/users/{uid}/purchases), same instant-grant
flow on the backend — the api-gateway DEV-937 change accepts a new
`paddle_store_data` field that purchaseman already knows how to
validate (live → sandbox dual-probe, GetSubscription / GetTransaction)
and feed into productCenter.UpdateClientProduct synchronously.

Public API:

  Qonversion.shared.sendPaddlePurchase({
    price, currency, purchased,
    transactionId, customerId, productId, type, subscriptionId?
  }) → Promise<UserPurchase>

Wire flow:
  QonversionInstance.sendPaddlePurchase
    → QonversionInternal.sendPaddlePurchase
    → PurchasesController.sendPaddlePurchase (logger + userId lookup)
    → PurchasesService.sendPaddlePurchase
    → RequestConfigurator.configurePaddlePurchaseRequest
        builds POST /v3/users/{userId}/purchases body
        { price, currency, purchased,
          paddle_store_data: { transaction_id, customer_id,
                               product_id, type,
                               subscription_id? (omitted for inapp) } }
    → api-gateway → purchaseman → Paddle API → product-center → done.

UserPurchase.stripeStoreData / paddleStoreData are now both optional
since a purchase row carries exactly one. PurchaseService grew a
small private executePurchaseRequest helper to dedupe the
success-or-throw shape between Stripe and Paddle.

Tests:
- PurchasesService: paddle success + paddle error mirroring stripe.
- PurchasesController: paddle success + paddle error.
- QonversionInternal: sendPaddlePurchase delegation + log line.
- RequestConfigurator: paddle subscription request + paddle inapp
  request (asserting subscription_id is omitted from the inapp body).

`yarn test` → 220/220.  `yarn tsc --noEmit` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nikita-ushakov nikita-ushakov changed the title feat: [DEV-937] sendPaddlePurchase — Paddle counterpart of sendStripePurchase feat: add sendPaddlePurchase May 22, 2026
Addresses the code-review findings on sendPaddlePurchase:

* Wire-enum mismatch: the SDK was sending `type: "inapp"` for one-time
  purchases, but the api-gateway's shared product-type enum is
  "non_recurring". Mapping now lives in RequestConfigurator
  (write: inapp → non_recurring) and PurchaseService (read:
  non_recurring → inapp). SDK callers keep the Paddle-native "inapp"
  string; the wire conversation matches the server contract.

* Restore back-compat for UserPurchase consumers. Splits the type
  into UserStripePurchase + UserPaddlePurchase (each with its store
  data required), and exposes UserPurchase as their union. The
  send*Purchase methods return the narrow variant, so existing
  Stripe-only consumers that did `purchase.stripeStoreData.productId`
  still compile without changes.

* executePurchaseRequest helper now typed as NetworkRequest instead
  of the awkward ReturnType<...> lookup, and is generic so each
  send*Purchase method returns its narrow type.

* Drop the gendered phrasing in the new JSDoc; align Stripe JSDoc
  too for consistency.

* New test asserts the inapp wire round-trip: api returns
  type:"non_recurring", SDK surfaces type:"inapp".

* Updated RequestConfigurator inapp test to assert the wire enum
  value, not the SDK enum value.

The matching api-gateway-side contract test (raw JSON POST asserting
the gateway accepts "non_recurring") lands in api-gateway as part of
the same review response.

yarn test → 221/221, yarn tsc --noEmit clean.
Addresses review SHOULD-FIX #5 and NIT #8:

* Replace the ternary `data.type === 'inapp' ? 'non_recurring' : 'subscription'`
  with a switch + `never` exhaustiveness guard. Adding a future
  PaddlePurchaseType variant now fails the type checker here instead
  of silently aliasing to "subscription" at runtime.

* On the read path, drop the `(purchase.paddleStoreData.type as string)`
  cast + in-place mutation pattern. Convert the wire literal to the
  SDK literal through a dedicated `paddleWireTypeToSdk` helper with
  the same exhaustive switch shape. The boundary is now an explicit
  PaddleStoreDataApi → PaddleStoreData transformation instead of a
  cast.

yarn test → 221/221, yarn tsc --noEmit clean.
The gateway and purchaseman never persist or act on
paddle_store_data.customer_id — it was accepted by every layer but
dropped after validation. Removing the field aligns the SDK with the
companion purchaseman (#491) and api-gateway (#556) PRs that drop the
matching openapi field.

Changes:
- PaddleStoreData: drop customerId
- PaddleStoreDataApi (wire): drop customer_id
- RequestConfigurator: drop customer_id from request body
- Tests: drop customerId fixtures everywhere

Caller impact: nothing — customer_id was never required to flow
through; it's already in the SDK consumer's context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nikita-ushakov nikita-ushakov merged commit ceca758 into develop Jun 2, 2026
3 checks passed
@nikita-ushakov nikita-ushakov deleted the nikita/dev-937-paddle-purchase-sdk branch June 2, 2026 10:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant