@crvouga/mockingbird-service-paddle 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/README.md +223 -0
- package/dist/chunk-GZYZKVKY.js +6635 -0
- package/dist/chunk-GZYZKVKY.js.map +7 -0
- package/dist/chunk-XBICSMA7.js +372 -0
- package/dist/chunk-XBICSMA7.js.map +7 -0
- package/dist/cli.js +19 -0
- package/dist/cli.js.map +7 -0
- package/dist/index.d.ts +1494 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +7 -0
- package/dist/server.d.ts +1836 -0
- package/dist/server.js +12 -0
- package/dist/server.js.map +7 -0
- package/package.json +102 -0
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# @crvouga/mockingbird-service-paddle
|
|
2
|
+
|
|
3
|
+
> Familiar calls. Faithful echoes. Part of [Mockingbird](https://github.com/crvouga/mockingbird).
|
|
4
|
+
|
|
5
|
+
Stateful mock of the **Paddle Billing** API for test suites. Customers, addresses, businesses,
|
|
6
|
+
products and prices behave as Paddle's do (validation, `invalid_field` errors, `include=`,
|
|
7
|
+
cursor pagination). Transactions carry **computed totals**. Subscriptions are created the way
|
|
8
|
+
Paddle creates them, when a transaction with recurring prices is paid, and the mock's admin
|
|
9
|
+
routes stand in for the hosted checkout and the billing engine: **pay a transaction, complete a
|
|
10
|
+
checkout in one call, run a renewal, fail a payment**. Every change produces the event Paddle
|
|
11
|
+
would emit, listed at `GET /events` and delivered as a `Paddle-Signature` webhook that the
|
|
12
|
+
official SDK's `paddle.webhooks.unmarshal` verifies.
|
|
13
|
+
|
|
14
|
+
- Operation coverage: [SUPPORT.md](https://github.com/crvouga/mockingbird/blob/main/packages/service/paddle/SUPPORT.md)
|
|
15
|
+
- `openapi.yaml` is hand-authored from Paddle's API reference and the wire types of
|
|
16
|
+
`@paddle/paddle-node-sdk@3.10.0`.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install -D @crvouga/mockingbird-service-paddle
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
ESM only. Node >= 22 or Bun >= 1.2. No native dependencies. Serve it with
|
|
25
|
+
`npx mockingbird-paddle serve`, `createServer` from `./server` (Node), or `createRuntime` with
|
|
26
|
+
any Fetch server.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
The SDK maps `environment` to a base URL and otherwise uses the value verbatim, so point it at
|
|
31
|
+
the mock by passing the mock's URL as the environment. Give the app the same `pdl_ntfset_…`
|
|
32
|
+
secret as `--webhook-secret`.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx mockingbird-paddle serve --port 8795 --fixtures \
|
|
36
|
+
--webhook-url http://127.0.0.1:3000/webhooks/paddle \
|
|
37
|
+
--webhook-secret "$PADDLE_WEBHOOK_SECRET" \
|
|
38
|
+
--payment-link https://pay.example.com/checkout
|
|
39
|
+
PADDLE_API_BASE_URL=http://127.0.0.1:8795 node app.js
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
import { createServer } from "@crvouga/mockingbird-service-paddle/server"
|
|
44
|
+
import { Paddle } from "@paddle/paddle-node-sdk"
|
|
45
|
+
|
|
46
|
+
const mock = await createServer({ paymentLink: "https://pay.example.com/checkout" })
|
|
47
|
+
// In TypeScript: `{ environment: mock.url as Environment }`.
|
|
48
|
+
const paddle = new Paddle("pdl_sdbx_apikey_test", { environment: mock.url })
|
|
49
|
+
|
|
50
|
+
const product = await paddle.products.create({ name: "Pro plan", taxCategory: "saas" })
|
|
51
|
+
const price = await paddle.prices.create({
|
|
52
|
+
productId: product.id,
|
|
53
|
+
description: "Pro monthly",
|
|
54
|
+
unitPrice: { amount: "2900", currencyCode: "USD" },
|
|
55
|
+
billingCycle: { interval: "month", frequency: 1 },
|
|
56
|
+
})
|
|
57
|
+
const customer = await paddle.customers.create({ email: "ada@example.com" })
|
|
58
|
+
const address = await paddle.addresses.create(customer.id, { countryCode: "US" })
|
|
59
|
+
const transaction = await paddle.transactions.create({
|
|
60
|
+
items: [{ priceId: price.id, quantity: 2 }],
|
|
61
|
+
customerId: customer.id,
|
|
62
|
+
addressId: address.id,
|
|
63
|
+
})
|
|
64
|
+
// transaction.status === "ready", transaction.details.totals.grandTotal === "5800",
|
|
65
|
+
// transaction.checkout.url === "https://pay.example.com/checkout?_ptxn=txn_…"
|
|
66
|
+
|
|
67
|
+
// The customer pays on the hosted checkout: the mock's admin route stands in for it.
|
|
68
|
+
await fetch(`${mock.url}/__admin/transactions/${transaction.id}/pay`, { method: "POST" })
|
|
69
|
+
const [subscription] = await paddle.subscriptions.list({ customerId: [customer.id] }).next()
|
|
70
|
+
// subscription.status === "active", subscription.nextBilledAt one month out
|
|
71
|
+
await mock.close()
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Without the SDK, the same over HTTP:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { createServer } from "@crvouga/mockingbird-service-paddle/server"
|
|
78
|
+
|
|
79
|
+
const mock = await createServer({ fixtures: true })
|
|
80
|
+
const headers = { authorization: "Bearer pdl_sdbx_apikey_test", "content-type": "application/json" }
|
|
81
|
+
const { data: products } = await (await fetch(`${mock.url}/products?include=prices`, { headers })).json()
|
|
82
|
+
const checkout = await (
|
|
83
|
+
await fetch(`${mock.url}/__admin/checkout`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers,
|
|
86
|
+
body: JSON.stringify({ email: "new@example.com", items: [{ price_id: products[0].prices[0].id }] }),
|
|
87
|
+
})
|
|
88
|
+
).json()
|
|
89
|
+
// checkout.transaction.status === "completed", checkout.subscription.status === "active"
|
|
90
|
+
await mock.close()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Routes
|
|
94
|
+
|
|
95
|
+
Every response is Paddle's envelope: `{data, meta: {request_id}}`, lists add
|
|
96
|
+
`meta.pagination: {per_page, next, has_more, estimated_total}`. Lists take `after=<id>` (the
|
|
97
|
+
cursor in `next`, an absolute URL the SDK follows as-is), `per_page` (default 50, max 200;
|
|
98
|
+
transactions 30; more than the maximum gets the maximum, as Paddle documents) and
|
|
99
|
+
`order_by=id[ASC]|id[DESC]` (default newest first). Errors are
|
|
100
|
+
`{error: {type, code, detail, documentation_url, errors?: [{field, message}]}, meta}`.
|
|
101
|
+
|
|
102
|
+
| Route | Behaviour |
|
|
103
|
+
| --- | --- |
|
|
104
|
+
| `GET/POST /customers`, `GET/PATCH /customers/{id}` | `{email, name?, custom_data?, locale?}`; a second customer with the same email is 409 `customer_already_exists`. Filters: `id`, `email`, `search`, `status`. `PATCH {status: "archived"}` archives. |
|
|
105
|
+
| `GET /customers/{id}/credit-balances` | Always `[]` (no credit is modelled). |
|
|
106
|
+
| `POST /customers/{id}/auth-token` | `{customer_auth_token: "pca_…", expires_at}` (30 minutes out). |
|
|
107
|
+
| `…/customers/{id}/addresses`, `…/businesses` (+`/{id}`) | Nested under their customer, as in Paddle; another customer's address is 404. `country_code` is required on an address, `name` on a business. |
|
|
108
|
+
| `GET/POST /products`, `GET/PATCH /products/{id}` | `{name, tax_category, description?, image_url?, custom_data?}`; `include=prices` embeds prices. |
|
|
109
|
+
| `GET/POST /prices`, `GET/PATCH /prices/{id}` | `{product_id, description, unit_price: {amount, currency_code}, billing_cycle?, trial_period?, quantity?, unit_price_overrides?, tax_mode?}`; `include=product`. Filters: `product_id`, `recurring`, `type`, `status`. |
|
|
110
|
+
| `POST /transactions` | `{items: [{price_id \| price: {…non-catalog}, quantity}], customer_id?, address_id?, business_id?, currency_code?, collection_mode?, billing_details?, status?, custom_data?}`. Status is `ready` with a customer and address, else `draft`; `collection_mode: manual` needs `billing_details.payment_terms` and can be created `billed` (invoice number, `billed_at`). `details` holds line items and totals; `checkout.url` is `<paymentLink>?_ptxn=<id>` when a payment link is configured. Recurring items must share one billing cycle; prices must match the transaction currency; an address or business must belong to the customer. Non-catalog `price` objects create `type: custom` prices (and products), only once the whole request has validated. |
|
|
111
|
+
| `GET /transactions`, `GET /transactions/{id}` | Filters: `id`, `customer_id`, `subscription_id`, `status`, `origin`, `collection_mode`, `invoice_number`, `created_at[GTE]`-style datetime bounds (`LT`, `LTE`, `GT`, `GTE` on `created_at`, `billed_at`, `updated_at`); `include=customer,address,business`. |
|
|
112
|
+
| `PATCH /transactions/{id}` | `draft` and `ready` transactions take every create field again (totals are recomputed); `billed` and `past_due` ones only `{status: "canceled"}`; anything else is 400 `transaction_immutable`. |
|
|
113
|
+
| `POST /transactions/preview` | Totals for `{items, customer_id?, address_id?, currency_code?, address?: {country_code}}` without storing anything (non-catalog `price` objects are priced, not created); `include_in_totals: false` items are listed, not summed. |
|
|
114
|
+
| `GET /transactions/{id}/invoice` | `{url}` for `billed`, `paid` and `completed` transactions; else 400 `transaction_invoice_not_available`. |
|
|
115
|
+
| `GET /subscriptions`, `GET /subscriptions/{id}` | Filters: `id`, `customer_id`, `address_id`, `price_id`, `status`, `collection_mode`, `scheduled_change_action`; `include=next_transaction,recurring_transaction_details` embeds the previews. `management_urls` are placeholder links on the mock's origin. |
|
|
116
|
+
| `PATCH /subscriptions/{id}` | `custom_data`, `next_billed_at`, `collection_mode` + `billing_details`, `customer_id`/`address_id`/`business_id`, `scheduled_change: null` (removes a scheduled pause or cancel), and `items` with a required `proration_billing_mode`: `*_immediately` modes bill what the change adds (new prices, quantity increases) in full at once as a `subscription_update` transaction, with no proration and no credit for what it removes; the other modes bill nothing now. Canceled subscriptions are 400 `subscription_update_when_canceled`. |
|
|
117
|
+
| `POST /subscriptions/{id}/activate` | `trialing` → `active`: bills the first period now and starts the billing cycle. |
|
|
118
|
+
| `POST /subscriptions/{id}/pause` | `{effective_from?: next_billing_period (default) \| immediately, resume_at?}`. Scheduled: `scheduled_change: {action: "pause", effective_at: next_billed_at}`. Immediate: `paused`, `paused_at`, no `next_billed_at`; with `resume_at` a `resume` change is scheduled. |
|
|
119
|
+
| `POST /subscriptions/{id}/resume` | `{effective_from: "immediately" \| <datetime>, on_resume?}`. Immediate: `active` with a fresh billing period, billed now; a datetime schedules the resume. On an active subscription with a scheduled pause, it removes the pause. |
|
|
120
|
+
| `POST /subscriptions/{id}/cancel` | `{effective_from?: next_billing_period (default) \| immediately}`. Scheduled: `scheduled_change: {action: "cancel", effective_at: next_billed_at}`, applied by the next renewal. Immediate: `canceled`, `canceled_at`, items `inactive`. |
|
|
121
|
+
| `POST /subscriptions/{id}/charge` | `{effective_from: immediately \| next_billing_period, items}` of non-recurring prices. Immediate: a completed `subscription_charge` transaction; otherwise the items are added to the next renewal's transaction. |
|
|
122
|
+
| `GET /events` | Every event the account produced, newest first: `{event_id, event_type, occurred_at, notification_id: null, data}`. |
|
|
123
|
+
|
|
124
|
+
Auth is `Authorization: Bearer <key>`; any key works. No header is 403 `authentication_missing`,
|
|
125
|
+
a non-Bearer header is 403 `authentication_malformed`. Validation failures are 400
|
|
126
|
+
`invalid_field` with `errors: [{field, message}]` (`email: required field`); a malformed JSON
|
|
127
|
+
body is 400 `invalid_json`; unknown entities are 404 `not_found` (`Entity ctm_… not found`).
|
|
128
|
+
Ids look like Paddle's (`ctm_01…`, `pri_01…`, `txn_01…`, `sub_01…`, `evt_01…`: a prefix and 26
|
|
129
|
+
lower-case alphanumerics) and are deterministic for a given history.
|
|
130
|
+
|
|
131
|
+
### Checkout and billing (admin)
|
|
132
|
+
|
|
133
|
+
Paddle has no API to pay a transaction or create a subscription: the hosted checkout does
|
|
134
|
+
that, and the billing engine renews. These routes stand in for them (all under `/__admin`,
|
|
135
|
+
namespaced like everything else):
|
|
136
|
+
|
|
137
|
+
| Route | Effect |
|
|
138
|
+
| --- | --- |
|
|
139
|
+
| `POST /__admin/transactions/:id/pay` `{card?: {type, last4, expiry_month, expiry_year, cardholder_name}}` | A `ready`, `billed` or `past_due` transaction is paid: a captured card payment, `completed`, an invoice number. Recurring items create the **subscription** (`trialing` when a price has a `trial_period`, else `active`, with `current_billing_period` and `next_billed_at`); a `past_due` renewal being paid returns its subscription to `active`. → `{transaction, subscription}` |
|
|
140
|
+
| `POST /__admin/checkout` `{email \| customer_id, name?, country_code?, postal_code?, address_id?, business_id?, items: [{price_id, quantity?}], custom_data?}` | A hosted-checkout completion in one call: finds or creates the customer (by email) and an address, creates a `web` transaction and pays it. → 201 `{transaction, subscription}` |
|
|
141
|
+
| `POST /__admin/subscriptions/:id/renew` | Runs the next billing date. A scheduled cancel or pause takes effect instead (`{subscription, transaction: null}`); a paused subscription with a scheduled resume resumes, billed from its `effective_at`; otherwise a completed `subscription_recurring` transaction for the recurring items (plus queued one-time charges), the billing period advances, and a trial ends into `active`. |
|
|
142
|
+
| `POST /__admin/subscriptions/:id/payment-failed` | The renewal's payment is declined: a `past_due` transaction with an `error` payment attempt, and the subscription goes `past_due`. Pay that transaction to recover. |
|
|
143
|
+
| `POST /__admin/seed` | The fixture account (below). → 201 with every created record. |
|
|
144
|
+
| `GET /__admin/events?type=` | The events list, oldest first, optionally one type. |
|
|
145
|
+
|
|
146
|
+
`--fixtures` (or `createRuntime({fixtures: true})`) seeds every namespace on first use, and again
|
|
147
|
+
after every reset, with:
|
|
148
|
+
Ada Lovelace (`ada@example.com`, a US address, a business), a **Pro plan** product with
|
|
149
|
+
monthly ($29), yearly ($290) and one-time onboarding ($99) prices, a **Starter plan** with a
|
|
150
|
+
14-day trial ($19/month), an active monthly subscription, a trialing Starter subscription
|
|
151
|
+
(Grace Hopper, `grace@example.com`), an unpaid `ready` transaction and a `billed` manual
|
|
152
|
+
invoice. `seedFixtures()` returns the records; seeding a namespace twice clashes on the emails.
|
|
153
|
+
Fixture events are listed (`GET /events`) but never delivered as notifications: they are the
|
|
154
|
+
account's pre-existing state, not activity.
|
|
155
|
+
|
|
156
|
+
### Webhooks
|
|
157
|
+
|
|
158
|
+
Every event is published to the configured endpoints as Paddle's notification body:
|
|
159
|
+
|
|
160
|
+
```json
|
|
161
|
+
{"event_id": "evt_01…", "event_type": "subscription.created", "occurred_at": "…",
|
|
162
|
+
"notification_id": "ntf_01…", "data": {"id": "sub_01…", "status": "active", "transaction_id": "txn_01…", "items": [ … ], … }}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Types: `customer|address|business|product|price.created|updated`, `transaction.created|ready|
|
|
166
|
+
billed|paid|completed|canceled|payment_failed|past_due|updated`, `subscription.created|
|
|
167
|
+
activated|trialing|updated|paused|resumed|canceled|past_due`. Each delivery carries
|
|
168
|
+
`Paddle-Signature: ts=<unix seconds>;h1=<hex HMAC-SHA256(secret, "<ts>:<raw body>")>` with a
|
|
169
|
+
wall-clock `ts` (even when the mock clock moves), which `paddle.webhooks.unmarshal(body,
|
|
170
|
+
secret, signature)` and `isSignatureValid` accept. Non-2xx answers are retried (immediately,
|
|
171
|
+
5 s, 5 min, 30 min, 2 h). `GET /__admin/webhooks`, `…/events`, `…/flush`, `…/:id/replay` and
|
|
172
|
+
`PUT /__admin/webhook-endpoints` (per-namespace receivers, `events: ["subscription.*"]`-style
|
|
173
|
+
filters are exact types or `*`) come with the contract.
|
|
174
|
+
|
|
175
|
+
Fault presets (`POST /__admin/faults {"preset": "<name>", "count"?: n}`; `GET /__admin/faults/presets`):
|
|
176
|
+
`invalid_token` (403 on every request), `rate_limited` (429 `too_many_requests` with
|
|
177
|
+
`retry-after: 2`, the SDK's `ApiError.retryAfter`), `transactions_500` (`POST /transactions`
|
|
178
|
+
answers 500 `internal_error`), `bad_gateway_html` (reads answer a 502 HTML page),
|
|
179
|
+
`network_drop` (`POST /transactions` drops the connection), `webhook_duplicate`,
|
|
180
|
+
`webhook_reorder`, `webhook_drop`.
|
|
181
|
+
|
|
182
|
+
### Namespaces
|
|
183
|
+
|
|
184
|
+
`new Paddle(key)` cannot add a namespace header on its own (it can with `customHeaders`), so
|
|
185
|
+
map API keys to namespaces: `PUT /__admin/credentials {"credentials": {"<PADDLE_API_KEY>":
|
|
186
|
+
"<namespace>"}}`. Also `x-mockingbird-namespace`, or a `/ns/<name>` prefix on the base URL
|
|
187
|
+
(`meta.pagination.next` keeps it).
|
|
188
|
+
|
|
189
|
+
### Deliberately not modelled
|
|
190
|
+
|
|
191
|
+
- **Tax and discounts.** Totals carry `tax: "0"` and `discount: "0"` whatever the address or
|
|
192
|
+
`tax_mode`; there are no discounts, discount groups or adjustments (refunds, credits), and no
|
|
193
|
+
credit balances. `unit_price_overrides` are honoured for the address's country.
|
|
194
|
+
- **Proration.** Changing a subscription's items with a `*_immediately` mode bills what the
|
|
195
|
+
change adds in full, credits nothing for what it removes; the next-period modes bill nothing now. `PATCH /subscriptions/{id}/preview` and
|
|
196
|
+
`…/charge/preview` are unsupported.
|
|
197
|
+
- **The hosted checkout, customer portal and Paddle.js.** `checkout.url` and `management_urls`
|
|
198
|
+
are links, not pages; `POST /__admin/checkout` and `…/pay` replace the checkout.
|
|
199
|
+
- **Payment methods and payment method changes**, payouts, reports, simulations, notification
|
|
200
|
+
settings through the API (endpoints are configured on the mock), invoice revisions, the
|
|
201
|
+
`imported` events, API key events and client tokens.
|
|
202
|
+
- **Time.** Nothing renews on its own: call `…/renew` (or `…/payment-failed`) when the test's
|
|
203
|
+
clock reaches `next_billed_at`. Retry schedules for past-due subscriptions are not modelled.
|
|
204
|
+
- **Rate limits**, except through `rate_limited`. Legacy (pre-2025) API key formats are
|
|
205
|
+
accepted like any other key.
|
|
206
|
+
|
|
207
|
+
## API
|
|
208
|
+
|
|
209
|
+
| Export | Kind | Description |
|
|
210
|
+
| --- | --- | --- |
|
|
211
|
+
| `PaddleAPI` | class | The in-process mock: `fetch(request)`, `reset()`, `events()`, `state`, and the billing methods the admin routes call: `createCustomer`, `createAddress`, `createBusiness`, `createProduct`, `createPrice`, `createTransaction`, `payTransaction`, `checkout`, `renewSubscription`, `failPayment`, `seedFixtures`. Options: `sqlite`, `now`, `namespace`, `publicNamespace`, `paymentLink`, `onEvent`, `fixtures`. |
|
|
212
|
+
| `createRuntime` | function | The mock with the full service contract (health, admin, namespaces, credentials, presets, `Paddle-Signature` webhooks, checkout and billing routes). Options: `webhooks: {url, secret, events?, retryDelaysMs?, fetch?}`, `paymentLink`, `fixtures`, `clock`, `seed`, `adminKey`, `onLog`, `sqlite`. |
|
|
213
|
+
| `paddleSigner` | function | The `Paddle-Signature` webhook signer (`ts=…;h1=…`). |
|
|
214
|
+
| `PADDLE_PRESETS` | object | Every named fault preset. |
|
|
215
|
+
| `PADDLE_NAMESPACE` | string | The service name, `"paddle"`. |
|
|
216
|
+
| `AUTH_TOKEN_TTL_MS` | number | The advertised lifetime of a customer auth token (30 min). |
|
|
217
|
+
| `PaddleError` | class | The error a billing method throws: `status`, `code`, `detail`, `errors?`, `toResponse(requestId)`. |
|
|
218
|
+
| `PaddleState` | class | The SQLite-backed collections (`customers`, `addresses`, `businesses`, `products`, `prices`, `transactions`, `subscriptions`, `events`) and `nextId(kind)`. |
|
|
219
|
+
| `ID_PREFIX` | object | Paddle's id prefix per entity (`customer: "ctm"`, `price: "pri"`, …). |
|
|
220
|
+
| `document`, `operationIds`, `supportedOperationIds` | values | The vendored OpenAPI contract and its operation ids. |
|
|
221
|
+
| `createServer`, `serveTarget`, `DEFAULT_PORT` (`./server`) | Node | Serve over `node:http`; the `serve` CLI target (`--webhook-url`, `--webhook-secret`, `--payment-link`, `--fixtures`); port 8795. |
|
|
222
|
+
|
|
223
|
+
Part of [mockingbird](https://github.com/crvouga/mockingbird).
|