Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ on:
jobs:
build:
runs-on: ubuntu-latest
env:
LAGO_OPENAPI_PIN: scripts/openapi-pin.json

strategy:
matrix:
Expand All @@ -29,3 +31,5 @@ jobs:
run: deno task build
- name: Typecheck webhook types
run: deno task typecheck
- name: Test client
run: deno task test
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,46 @@ The contribution documentation is available [here](https://github.com/getlago/la
## License

Lago JavaScript client is distributed under [MIT license](LICENSE).

## Payment list filters

```ts
const result = await client.payments.findAllPayments({
"payment_status[]": ["succeeded", "failed"],
currency: "EUR",
amount_from: "5000000000",
amount_to: "9223372036854775807",
created_at_from: "2026-09-01",
});
const customerResult = await client.customers.findAllCustomerPayments("cust_1", {
"payment_status[]": ["succeeded"],
currency: "EUR",
});
```

The generated query types include all payment filters on both endpoints. Array
keys include `[]` and serialize as repeated query parameters. Filters combine
with AND; entries within an array combine with OR. Use decimal strings for cents
above `Number.MAX_SAFE_INTEGER` to preserve exact 64-bit bounds. Numbers remain
supported for smaller bounds, including zero. Date bounds are inclusive in the
organization timezone; receipt and invoice numbers match exactly, ignoring case.
Payment response types are unchanged.

### Generating against a feature spec

Generated `openapi/` and `npm/` files remain ignored. Release generation defaults
to the published spec. To reproduce this feature's CI build before publication:

```sh
LAGO_OPENAPI_PIN=scripts/openapi-pin.json deno task build
deno task typecheck
deno task test
```

The pin records an immutable OpenAPI commit and SHA-256 checksum, consumed by
both generators. For local development, use
`LAGO_OPENAPI_SPEC=../lago-openapi/openapi.yaml deno task build`. Set both variables
to verify a local file against the pin. Update the pin after upstream changes;
release builds can use the published default once the spec PR is merged and
published. The post-generation script adds decimal-string input support only to
payment list amount bounds.
4 changes: 2 additions & 2 deletions deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
"generate:npm-package": "deno run -A scripts/build_npm.ts",
// swagger-typescript-api can't map text/plain, so fetchPublicKey needs a
// patch after generation (scripts/patch_openapi_client.ts).
"generate:openapi": "npx -y swagger-typescript-api@13.0.2 -p https://swagger.getlago.com/openapi.yaml --union-enums -o ./openapi -n client.ts && deno run -A scripts/patch_openapi_client.ts",
"generate:openapi": "deno run -A scripts/generate.ts client",
// Generates ./openapi/webhooks.ts containing typed webhook payload schemas from the OpenAPI 3.1 `webhooks:` key. swagger-typescript-api does not handle that key, so we use openapi-typescript here instead. The output is consumed by ../webhook_types.ts (hand-written) which exposes the user-facing helpers.
"generate:webhooks": "npx -y openapi-typescript@7.13.0 https://swagger.getlago.com/openapi.yaml -o ./openapi/webhooks.ts",
"generate:webhooks": "deno run -A scripts/generate.ts webhooks",
// Typechecks the public surface and the webhook type tests. Cheap (no runtime, no network), suitable for CI. Run after `generate:openapi` and `generate:webhooks` so the generated files exist on disk.
"typecheck": "deno check mod.ts webhook_types.ts tests/webhook_types.test.ts",
"test": "deno test ./tests --parallel"
Expand Down
52 changes: 52 additions & 0 deletions scripts/generate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Both generators consume the same spec source. Release builds keep the
// published default; CI can validate a feature against an immutable spec pin.
const target = Deno.args[0];
if (target !== "client" && target !== "webhooks") {
throw new Error("Usage: generate.ts client|webhooks");
}

const pinPath = Deno.env.get("LAGO_OPENAPI_PIN");
const pin: { url: string; sha256: string } | undefined = pinPath
? JSON.parse(await Deno.readTextFile(pinPath))
: undefined;
const source = Deno.env.get("LAGO_OPENAPI_SPEC") ?? pin?.url ??
"https://swagger.getlago.com/openapi.yaml";
const bytes = /^https?:\/\//.test(source)
? await (async () => {
const response = await fetch(source);
if (!response.ok) {
throw new Error(`Spec download failed: ${response.status}`);
}
return new Uint8Array(await response.arrayBuffer());
})()
: await Deno.readFile(source);
if (pin) {
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
const hash = Array.from(digest, (b) => b.toString(16).padStart(2, "0")).join(
"",
);
if (hash !== pin.sha256) throw new Error("OpenAPI spec checksum mismatch");
}

const input = await Deno.makeTempFile({ suffix: ".yaml" });
try {
await Deno.writeFile(input, bytes);
const args = target === "client"
? [
"-y",
"swagger-typescript-api@13.0.2",
"-p",
input,
"--union-enums",
"-o",
"./openapi",
"-n",
"client.ts",
]
: ["-y", "openapi-typescript@7.13.0", input, "-o", "./openapi/webhooks.ts"];
const result = await new Deno.Command("npx", { args }).spawn().status;
if (!result.success) throw new Error(`Generator failed: ${result.code}`);
if (target === "client") await import("./patch_openapi_client.ts");
} finally {
await Deno.remove(input);
}
4 changes: 4 additions & 0 deletions scripts/openapi-pin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"url": "https://raw.githubusercontent.com/getlago/lago-openapi/48c6fa990666c26ccb945e1f7e279cbdfb47f1e8/openapi.yaml",
"sha256": "c4edc0affd837e4d1e1ea873bb4ef697912e84603193076474b62000ad153d49"
}
19 changes: 19 additions & 0 deletions scripts/patch_openapi_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,25 @@ for (const { match, replacement } of patches) {
patched = patched.replace(match, replacement);
}

// Query strings can preserve all int64 cents when callers supply a decimal
// string. Keep number compatibility and leave every response type unchanged.
for (const operation of ["findAllPayments", "findAllCustomerPayments"]) {
const start = patched.indexOf(` ${operation}: (`);
const end = patched.indexOf(" params: RequestParams", start);
if (start < 0 || end < 0) {
errors.push(`Missing generated operation: ${operation}`);
continue;
}
let query = patched.slice(start, end);
for (const bound of ["amount_from", "amount_to"]) {
const original = `${bound}?: number;`;
const replacement = `${bound}?: number | string;`;
// A published spec without payment filters still builds during rollout.
if (query.includes(original)) query = query.replace(original, replacement);
}
patched = patched.slice(0, start) + query + patched.slice(end);
}

if (errors.length > 0) {
console.error("patch_openapi_client.ts failed:\n" + errors.join("\n\n"));
Deno.exit(1);
Expand Down
11 changes: 9 additions & 2 deletions tests/add_on.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const addOnResponse = {
amount_cents: 1000,
amount_currency: "EUR",
description: "description",
invoice_display_name: null,
created_at: "2022-04-29T08:59:51Z",
},
} as const satisfies AddOn;
Expand Down Expand Up @@ -102,7 +103,10 @@ Deno.test(
route: "GET@/api/v1/add_ons",
clientPath: ["addOns", "findAllAddOns"],
inputParams: [],
responseObject: { add_ons: [addOn.add_on] },
responseObject: {
meta: { current_page: 1, total_pages: 1, total_count: 1 },
add_ons: [addOnResponse.add_on],
},
status: 200,
});
},
Expand All @@ -117,7 +121,10 @@ Deno.test(
route: "GET@/api/v1/add_ons",
clientPath: ["addOns", "findAllAddOns"],
inputParams: [{ page: 3, per_page: 2 }],
responseObject: { add_ons: [addOn.add_on] },
responseObject: {
meta: { current_page: 1, total_pages: 1, total_count: 1 },
add_ons: [addOnResponse.add_on],
},
status: 200,
urlParams: { page: "3", per_page: "2" },
});
Expand Down
56 changes: 34 additions & 22 deletions tests/applied_coupon.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AppliedCouponInput, AppliedCoupons } from "../mod.ts";
import type { AppliedCouponInput, AppliedCouponsPaginated } from "../mod.ts";
import { lagoTest, unprocessableErrorResponse } from "./utils.ts";

const appliedCoupon = {
Expand All @@ -8,30 +8,34 @@ const appliedCoupon = {
},
} as const satisfies AppliedCouponInput;

const appliedCouponResponse = {
applied_coupon: {
lago_id: "b7ab2926-1de8-4428-9bcd-779314ac129b",
lago_coupon_id: "b7ab2926-1de8-4428-9bcd-779314ac129b",
coupon_code: "coupon-code",
coupon_name: "Coupon",
status: "active",
external_customer_id: "5eb02857-a71e-4ea2-bcf9-57d3a41bc6ba",
lago_customer_id: "99a6094e-199b-4101-896a-54e927ce7bd7",
amount_cents: 123,
amount_currency: "EUR",
frequency: "once",
frequency_duration: undefined,
percentage_rate: undefined,
expiration_at: "2022-04-29",
created_at: "2022-04-29T08:59:51Z",
terminated_at: "2022-04-29T08:59:51Z",
},
} as const;

Deno.test("Successfully sent apply coupon responds with 2xx", async (t) => {
await lagoTest({
t,
testType: "200",
route: "POST@/api/v1/applied_coupons",
clientPath: ["appliedCoupons", "applyCoupon"],
inputParams: [appliedCoupon],
responseObject: {
applied_coupon: {
lago_id: "b7ab2926-1de8-4428-9bcd-779314ac129b",
lago_coupon_id: "b7ab2926-1de8-4428-9bcd-779314ac129b",
coupon_code: "coupon-code",
external_customer_id: "5eb02857-a71e-4ea2-bcf9-57d3a41bc6ba",
lago_customer_id: "99a6094e-199b-4101-896a-54e927ce7bd7",
amount_cents: 123,
amount_currency: "EUR",
frequency: "once",
frequency_duration: undefined,
percentage_rate: undefined,
expiration_at: "2022-04-29",
created_at: "2022-04-29T08:59:51Z",
terminated_at: "2022-04-29T08:59:51Z",
},
},
responseObject: appliedCouponResponse,
status: 200,
});
});
Expand All @@ -58,8 +62,12 @@ Deno.test(
clientPath: ["appliedCoupons", "findAllAppliedCoupons"],
inputParams: [],
responseObject: {
applied_coupons: [appliedCoupon.applied_coupon],
} satisfies AppliedCoupons,
meta: { current_page: 1, total_pages: 1, total_count: 1 },
applied_coupons: [{
...appliedCouponResponse.applied_coupon,
credits: [],
}],
} satisfies AppliedCouponsPaginated,
status: 200,
});
},
Expand All @@ -78,8 +86,12 @@ Deno.test(
page: 3,
}],
responseObject: {
applied_coupons: [appliedCoupon.applied_coupon],
} satisfies AppliedCoupons,
meta: { current_page: 1, total_pages: 1, total_count: 1 },
applied_coupons: [{
...appliedCouponResponse.applied_coupon,
credits: [],
}],
} satisfies AppliedCouponsPaginated,
status: 200,
urlParams: { page: "3", per_page: "2" },
});
Expand Down
32 changes: 19 additions & 13 deletions tests/billable_metric.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { BillableMetric, BillableMetricInput } from "../mod.ts";
import type {
BillableMetric,
BillableMetricCreateInput,
BillableMetricUpdateInput,
} from "../mod.ts";
import { lagoTest, unprocessableErrorResponse } from "./utils.ts";

const billableMetric = {
Expand All @@ -7,12 +11,10 @@ const billableMetric = {
code: "code1",
aggregation_type: "sum_agg",
field_name: "field_name",
group: {
key: "country",
values: ["france", "italy", "spain"],
},
recurring: false,
filters: [{ key: "country", values: ["france", "italy", "spain"] }],
},
} satisfies BillableMetricInput;
} satisfies BillableMetricCreateInput;

const response = {
billable_metric: {
Expand All @@ -23,10 +25,8 @@ const response = {
aggregation_type: "sum_agg",
field_name: "field_name",
created_at: "2022-04-29T08:59:51Z",
group: {
key: "country",
values: ["france", "italy", "spain"],
},
recurring: false,
filters: [{ key: "country", values: ["france", "italy", "spain"] }],
},
} satisfies BillableMetric;

Expand Down Expand Up @@ -66,7 +66,7 @@ Deno.test(
"code1",
{
billable_metric: { name: "new name", field_name: "new_field_name" },
} satisfies BillableMetricInput,
} satisfies BillableMetricUpdateInput,
],
responseObject: response,
status: 200,
Expand Down Expand Up @@ -113,7 +113,10 @@ Deno.test(
route: "GET@/api/v1/billable_metrics",
clientPath: ["billableMetrics", "findAllBillableMetrics"],
inputParams: [],
responseObject: { billable_metrics: [response.billable_metric] },
responseObject: {
meta: { current_page: 1, total_pages: 1, total_count: 1 },
billable_metrics: [response.billable_metric],
},
status: 200,
});
},
Expand All @@ -131,7 +134,10 @@ Deno.test(
per_page: 2,
page: 3,
}],
responseObject: { billable_metrics: [response.billable_metric] },
responseObject: {
meta: { current_page: 1, total_pages: 1, total_count: 1 },
billable_metrics: [response.billable_metric],
},
status: 200,
urlParams: { page: "3", per_page: "2" },
});
Expand Down
12 changes: 10 additions & 2 deletions tests/coupon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const response = {
coupon_type: "fixed_amount",
percentage_rate: undefined,
reusable: false,
limited_plans: false,
limited_billable_metrics: false,
created_at: "2022-04-29T08:59:51Z",
},
} as const satisfies Coupon;
Expand Down Expand Up @@ -98,7 +100,10 @@ Deno.test("Successfully sent coupon find all request responds with 2xx", async (
route: "GET@/api/v1/coupons",
clientPath: ["coupons", "findAllCoupons"],
inputParams: [],
responseObject: { coupons: [response.coupon] },
responseObject: {
meta: { current_page: 1, total_pages: 1, total_count: 1 },
coupons: [response.coupon],
},
status: 200,
});
});
Expand All @@ -112,7 +117,10 @@ Deno.test(
route: "GET@/api/v1/coupons",
clientPath: ["coupons", "findAllCoupons"],
inputParams: [{ page: 3, per_page: 2 }],
responseObject: { coupons: [response.coupon] },
responseObject: {
meta: { current_page: 1, total_pages: 1, total_count: 1 },
coupons: [response.coupon],
},
status: 200,
urlParams: { page: "3", per_page: "2" },
});
Expand Down
Loading