diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b0533b..d4a0881 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,8 @@ on: jobs: build: runs-on: ubuntu-latest + env: + LAGO_OPENAPI_PIN: scripts/openapi-pin.json strategy: matrix: @@ -29,3 +31,5 @@ jobs: run: deno task build - name: Typecheck webhook types run: deno task typecheck + - name: Test client + run: deno task test diff --git a/README.md b/README.md index 6c4bb13..25e4ebf 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/deno.jsonc b/deno.jsonc index e9b8769..2be632e 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -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" diff --git a/scripts/generate.ts b/scripts/generate.ts new file mode 100644 index 0000000..3f34a2d --- /dev/null +++ b/scripts/generate.ts @@ -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); +} diff --git a/scripts/openapi-pin.json b/scripts/openapi-pin.json new file mode 100644 index 0000000..904c080 --- /dev/null +++ b/scripts/openapi-pin.json @@ -0,0 +1,4 @@ +{ + "url": "https://raw.githubusercontent.com/getlago/lago-openapi/48c6fa990666c26ccb945e1f7e279cbdfb47f1e8/openapi.yaml", + "sha256": "c4edc0affd837e4d1e1ea873bb4ef697912e84603193076474b62000ad153d49" +} diff --git a/scripts/patch_openapi_client.ts b/scripts/patch_openapi_client.ts index 74b7942..7bb13ea 100644 --- a/scripts/patch_openapi_client.ts +++ b/scripts/patch_openapi_client.ts @@ -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); diff --git a/tests/add_on.test.ts b/tests/add_on.test.ts index 9d6586e..6d66eb4 100644 --- a/tests/add_on.test.ts +++ b/tests/add_on.test.ts @@ -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; @@ -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, }); }, @@ -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" }, }); diff --git a/tests/applied_coupon.test.ts b/tests/applied_coupon.test.ts index 0a13c3b..5834b88 100644 --- a/tests/applied_coupon.test.ts +++ b/tests/applied_coupon.test.ts @@ -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 = { @@ -8,6 +8,26 @@ 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, @@ -15,23 +35,7 @@ Deno.test("Successfully sent apply coupon responds with 2xx", async (t) => { 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, }); }); @@ -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, }); }, @@ -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" }, }); diff --git a/tests/billable_metric.test.ts b/tests/billable_metric.test.ts index 3130d79..224f5a6 100644 --- a/tests/billable_metric.test.ts +++ b/tests/billable_metric.test.ts @@ -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 = { @@ -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: { @@ -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; @@ -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, @@ -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, }); }, @@ -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" }, }); diff --git a/tests/coupon.test.ts b/tests/coupon.test.ts index 94f5e29..ef77970 100644 --- a/tests/coupon.test.ts +++ b/tests/coupon.test.ts @@ -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; @@ -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, }); }); @@ -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" }, }); diff --git a/tests/credit_note.test.ts b/tests/credit_note.test.ts index 5fcdbbe..fb43fb8 100644 --- a/tests/credit_note.test.ts +++ b/tests/credit_note.test.ts @@ -1,6 +1,6 @@ import type { CreditNote, - CreditNoteInput, + CreditNoteCreateInput, CreditNoteUpdateInput, } from "../mod.ts"; import { lagoTest, unprocessableErrorResponse } from "./utils.ts"; @@ -17,7 +17,7 @@ const creditNote = { }, ], }, -} satisfies CreditNoteInput; +} satisfies CreditNoteCreateInput; const creditNoteUpdate = { credit_note: { @@ -29,6 +29,11 @@ const response = { "credit_note": { "lago_id": "183da83c-c007-4fbb-afcd-b00c07c41ffe", "sequential_id": 1234, + "billing_entity_code": "default", + "currency": "EUR", + "taxes_rate": 0, + "offset_amount_cents": 0, + "coupons_adjustment_amount_cents": 0, "number": "123456789", "lago_invoice_id": "144da83c-c007-4fbb-afcd-b00c07c41ffe", "invoice_number": "123456789", @@ -38,17 +43,11 @@ const response = { "reason": "duplicated_charge", "description": "description", "total_amount_cents": 1220, - "total_amount_currency": "EUR", - "vat_amount_cents": 20, - "vat_amount_currency": "EUR", - "sub_total_vat_excluded_amount_cents": 1000, - "sub_total_vat_excluded_amount_currency": "EUR", + "taxes_amount_cents": 20, + "sub_total_excluding_taxes_amount_cents": 1000, "balance_amount_cents": 20, - "balance_amount_currency": "EUR", "credit_amount_cents": 20, - "credit_amount_currency": "EUR", "refund_amount_cents": 20, - "refund_amount_currency": "EUR", "created_at": "2022-09-14T16:35:31Z", "updated_at": "2022-09-14T16:35:31Z", "file_url": "https://example.com", @@ -68,7 +67,6 @@ const response = { "city": "City", "url": "https://example.com", "phone": "3551234567", - "lago_url": "https://lago.url", "legal_name": "name1", "legal_number": "10000", "currency": "EUR", @@ -76,11 +74,9 @@ const response = { "applicable_timezone": "UTC", "billing_configuration": { "invoice_grace_period": 3, - "vat_rate": 25, "payment_provider": "stripe", "provider_customer_id": "123456", "sync_with_provider": true, - "additionalProp1": {}, }, }, "items": [ @@ -90,15 +86,25 @@ const response = { "amount_currency": "EUR", "fee": { "lago_id": "183da83c-c007-4fbb-afcd-b00c07c41ffe", - "lago_group_id": "183da83c-c007-4fbb-afcd-b00c07c41ffe", + "taxes_rate": 0, + "precise_unit_amount": "480", + "total_aggregated_units": "2.5", + "total_amount_cents": 1200, + "total_amount_currency": "EUR", + "pay_in_advance": false, + "invoiceable": true, + "payment_status": "succeeded", + "sub_total_excluding_taxes_amount_cents": 1200, + "sub_total_excluding_taxes_precise_amount_cents": "1200", "amount_cents": 1200, "amount_currency": "EUR", - "vat_amount_cents": 1200, - "vat_amount_currency": "EUR", - "units": 2.5, + "taxes_amount_cents": 1200, + "units": "2.5", "events_count": 5, "item": { "type": "charge", + "lago_item_id": "183da83c-c007-4fbb-afcd-b00c07c41ffe", + "item_type": "BillableMetric", "code": "code", "name": "name", }, @@ -163,7 +169,7 @@ Deno.test("Successfully find credit note request", async (t) => { route: "GET@/api/v1/credit_notes/id", clientPath: ["creditNotes", "findCreditNote"], inputParams: ["id"], - responseObject: creditNote, + responseObject: response, status: 200, }); }); @@ -175,7 +181,10 @@ Deno.test("Successfully sent find all credit notes request", async (t) => { route: "GET@/api/v1/credit_notes", clientPath: ["creditNotes", "findAllCreditNotes"], inputParams: [], - responseObject: { credit_notes: [creditNote.credit_note] }, + responseObject: { + meta: { current_page: 1, total_pages: 1, total_count: 1 }, + credit_notes: [response.credit_note], + }, status: 200, }); }); @@ -187,7 +196,7 @@ Deno.test("Successfully request invoice download", async (t) => { route: "POST@/api/v1/credit_notes/lago_id/download", clientPath: ["creditNotes", "downloadCreditNote"], inputParams: ["lago_id"], - responseObject: creditNote, + responseObject: response, status: 200, }); }); @@ -204,7 +213,10 @@ Deno.test( per_page: 2, page: 3, }], - responseObject: { credit_notes: [creditNote.credit_note] }, + responseObject: { + meta: { current_page: 1, total_pages: 1, total_count: 1 }, + credit_notes: [response.credit_note], + }, status: 200, urlParams: { page: "3", per_page: "2" }, }); diff --git a/tests/customer.test.ts b/tests/customer.test.ts index 41856b7..dcaa3fc 100644 --- a/tests/customer.test.ts +++ b/tests/customer.test.ts @@ -1,6 +1,6 @@ import type { Customer, - CustomerInput, + CustomerCreateInput, CustomerProjectedUsage, CustomerUsage, } from "../mod.ts"; @@ -27,7 +27,6 @@ const customer = { "city": "City", "url": "https://example.com", "phone": "3551234567", - "lago_url": "https://lago.url", "legal_name": "name1", "legal_number": "10000", "currency": "EUR", @@ -35,11 +34,9 @@ const customer = { "applicable_timezone": "UTC", "billing_configuration": { "invoice_grace_period": 3, - "vat_rate": 25, "payment_provider": "stripe", "provider_customer_id": "123456", "sync_with_provider": true, - "additionalProp1": {}, }, }, } as const satisfies Customer; @@ -57,21 +54,18 @@ const customerInput = { "city": "City", "url": "https://example.com", "phone": "3551234567", - "lago_url": "https://lago.url", "legal_name": "name1", "legal_number": "10000", "currency": "EUR", "timezone": "Europe/Paris", "billing_configuration": { "invoice_grace_period": 3, - "vat_rate": 25, "payment_provider": "stripe", "provider_customer_id": "123456", "sync_with_provider": true, - "additionalProp1": {}, }, }, -} as const satisfies CustomerInput; +} as const satisfies CustomerCreateInput; const customerUsage = { "customer_usage": { @@ -79,14 +73,13 @@ const customerUsage = { "to_datetime": "2022-09-14T00:00:00Z", "issuing_date": "2022-09-15T00:00:00Z", "amount_cents": 1200, - "amount_currency": "EUR", "total_amount_cents": 1400, - "total_amount_currency": "EUR", - "vat_amount_cents": 200, - "vat_amount_currency": "EUR", + "taxes_amount_cents": 200, "charges_usage": [ { - "units": 3, + "units": "3", + "total_aggregated_units": "3", + "events_count": 3, "amount_cents": 1200, "amount_currency": "EUR", "charge": { @@ -99,18 +92,10 @@ const customerUsage = { "code": "code", "aggregation_type": "count_agg", }, - "groups": [ - { - "lago_id": "278da83c-c007-4fbb-afcd-b00c07c41utg", - "key": "key", - "value": "value", - "units": 3.5, - "amount_cents": 1200, - }, - ], "filters": [ { "units": "1.0", + "total_aggregated_units": "1.0", "amount_cents": 600, "events_count": 3, "values": { @@ -129,6 +114,7 @@ const customerUsage = { "grouped_usage": [ { "units": "2.0", + "total_aggregated_units": "2.0", "amount_cents": 800, "events_count": 4, "grouped_by": { @@ -137,6 +123,7 @@ const customerUsage = { "filters": [ { "units": "1.0", + "total_aggregated_units": "1.0", "amount_cents": 400, "events_count": 2, "values": { diff --git a/tests/event.test.ts b/tests/event.test.ts index b804502..2955c15 100644 --- a/tests/event.test.ts +++ b/tests/event.test.ts @@ -1,4 +1,4 @@ -import type { BatchEventInput, EventInput } from "../mod.ts"; +import type { EventBatchInput, EventInput } from "../mod.ts"; import { lagoTest, notFoundErrorResponse, @@ -8,15 +8,11 @@ import { const eventInput = { event: { transaction_id: "transactionId", - external_customer_id: "externalCustomerId", + external_subscription_id: "externalSubscriptionId", code: "code", }, -} as const satisfies (EventInput | BatchEventInput); -// const batchEvent = new BatchEvent({ -// transactionId: "transactionId", -// externalSubscriptionIds: ["123", "456"], -// code: "code", -// }); +} as const satisfies EventInput; +const batchInput = { events: [eventInput.event] } satisfies EventBatchInput; Deno.test("Successfully sent event responds with 2xx", async (t) => { await lagoTest({ @@ -47,7 +43,7 @@ Deno.test("Successfully sent batch event responds with 2xx", async (t) => { testType: "200", route: "POST@/api/v1/events/batch", clientPath: ["events", "createBatchEvents"], - inputParams: [eventInput], + inputParams: [batchInput], status: 200, }); }); diff --git a/tests/invoice.test.ts b/tests/invoice.test.ts index eb68d06..d1b730c 100644 --- a/tests/invoice.test.ts +++ b/tests/invoice.test.ts @@ -1,24 +1,28 @@ -import type { Invoice, Invoices } from "../mod.ts"; +import type { Invoice, InvoicesPaginated } from "../mod.ts"; import { lagoTest, unprocessableErrorResponse } from "./utils.ts"; const invoiceResponse = { "invoice": { "lago_id": "183da83c-c007-4fbb-afcd-b00c07c41ffe", - "sequential_id": 12345, + "billing_entity_code": "default", + "currency": "EUR", + "fees_amount_cents": 1200, + "coupons_amount_cents": 0, + "credit_notes_amount_cents": 0, + "sub_total_excluding_taxes_amount_cents": 1200, + "sub_total_including_taxes_amount_cents": 1220, + "prepaid_credit_amount_cents": 0, + "progressive_billing_credit_amount_cents": 0, + "version_number": 4, + "created_at": "2022-09-14T16:35:31Z", + "updated_at": "2022-09-14T16:35:31Z", "number": "222345", "issuing_date": "2022-09-14T16:35:31Z", "invoice_type": "subscription", "status": "finalized", "payment_status": "pending", - "amount_cents": 1200, - "amount_currency": "EUR", - "vat_amount_cents": 20, - "vat_amount_currency": "EUR", - "credit_amount_cents": 20, - "credit_amount_currency": "EUR", + "taxes_amount_cents": 20, "total_amount_cents": 1220, - "total_amount_currency": "EUR", - "legacy": true, "file_url": "https://example.com", "customer": { "lago_id": "183da83c-c007-4fbb-afcd-b00c07c41ffe", @@ -36,7 +40,6 @@ const invoiceResponse = { "city": "City", "url": "https://example.com", "phone": "3551234567", - "lago_url": "https://lago.url", "legal_name": "name1", "legal_number": "10000", "currency": "EUR", @@ -44,11 +47,9 @@ const invoiceResponse = { "applicable_timezone": "UTC", "billing_configuration": { "invoice_grace_period": 3, - "vat_rate": 25, "payment_provider": "stripe", "provider_customer_id": "123456", "sync_with_provider": true, - "additionalProp1": {}, }, }, "subscriptions": [ @@ -69,20 +70,36 @@ const invoiceResponse = { "previous_plan_code": "previous_code", "next_plan_code": "next_code", "downgrade_plan_date": "2022-09-14T16:35:31Z", + "ending_at": null, + "trial_ended_at": null, + "current_billing_period_started_at": null, + "current_billing_period_ending_at": null, + "on_termination_credit_note": "credit", + "on_termination_invoice": "generate", }, ], "fees": [ { "lago_id": "183da83c-c007-4fbb-afcd-b00c07c41ffe", - "lago_group_id": "183da83c-c007-4fbb-afcd-b00c07c41ffe", + "taxes_rate": 0, + "precise_unit_amount": "480", + "total_aggregated_units": "2.5", + "total_amount_cents": 1200, + "total_amount_currency": "EUR", + "pay_in_advance": false, + "invoiceable": true, + "payment_status": "succeeded", + "sub_total_excluding_taxes_amount_cents": 1200, + "sub_total_excluding_taxes_precise_amount_cents": "1200", "amount_cents": 1200, "amount_currency": "EUR", - "vat_amount_cents": 1200, - "vat_amount_currency": "EUR", - "units": 2.5, + "taxes_amount_cents": 1200, + "units": "2.5", "events_count": 5, "item": { "type": "charge", + "lago_item_id": "183da83c-c007-4fbb-afcd-b00c07c41ffe", + "item_type": "BillableMetric", "code": "code", "name": "name", }, @@ -98,11 +115,13 @@ const invoiceResponse = { ], "credits": [ { + "before_taxes": true, + "invoice": { "lago_id": "invoice-id", "payment_status": "succeeded" }, "lago_id": "183da83c-c007-4fbb-afcd-b00c07c41ffe", "amount_cents": 1200, "amount_currency": "EUR", "item": { - "lago_id": "183da83c-c007-4fbb-afcd-b00c07c41ffe", + "lago_item_id": "183da83c-c007-4fbb-afcd-b00c07c41ffe", "type": "coupon", "code": "code", "name": "name", @@ -113,8 +132,9 @@ const invoiceResponse = { } satisfies Invoice; const invoicesResponse = { + meta: { current_page: 1, total_pages: 1, total_count: 1 }, invoices: [invoiceResponse.invoice], -} satisfies Invoices; +} satisfies InvoicesPaginated; Deno.test( "Successfully sent invoice update payment status responds with 2xx", diff --git a/tests/organization.test.ts b/tests/organization.test.ts index 0a89012..18925de 100644 --- a/tests/organization.test.ts +++ b/tests/organization.test.ts @@ -1,4 +1,4 @@ -import type { Organization, OrganizationInput } from "../mod.ts"; +import type { Organization, OrganizationUpdateInput } from "../mod.ts"; import { lagoTest, unprocessableErrorResponse } from "./utils.ts"; const orgInput = { @@ -16,15 +16,17 @@ const orgInput = { "timezone": "Europe/Paris", "billing_configuration": { "invoice_footer": "text", - "vat_rate": 25, "invoice_grace_period": 5, }, }, -} satisfies OrganizationInput; +} satisfies OrganizationUpdateInput; const orgResponse = { "organization": { "lago_id": "183da83c-c007-4fbb-afcd-b00c07c41ffe", + "document_numbering": "per_customer", + "document_number_prefix": "LAG", + "email_settings": [], "name": "example name", "created_at": "2022-09-14T16:35:31Z", "webhook_url": "https://example.com", @@ -40,7 +42,6 @@ const orgResponse = { "timezone": "UTC", "billing_configuration": { "invoice_footer": "text", - "vat_rate": 25, "invoice_grace_period": 5, }, }, diff --git a/tests/payment.test.ts b/tests/payment.test.ts new file mode 100644 index 0000000..e807fac --- /dev/null +++ b/tests/payment.test.ts @@ -0,0 +1,102 @@ +import { assertEquals } from "../dev_deps.ts"; +import { Client } from "../mod.ts"; +import type { Api } from "../mod.ts"; +import { createMockFetch } from "./utils.ts"; + +type PaymentFilters = NonNullable< + Parameters["payments"]["findAllPayments"]>[0] +>; +type CustomerPaymentFilters = NonNullable< + Parameters["customers"]["findAllCustomerPayments"]>[1] +>; + +const filters = { + page: 2, + per_page: 5, + external_customer_id: "cust_1", + invoice_id: "1a901a90-1a90-1a90-1a90-1a901a901a90", + "payment_status[]": ["succeeded", "failed"], + "payment_statuses[]": ["pending", "processing"], + amount_from: "9007199254740993", + amount_to: "9223372036854775807", + receipt_number: "Rcpt & +/#1", + created_at_from: "2026-09-01", + created_at_to: "2026-09-07", + "payment_provider_type[]": ["stripe", "gocardless"], + currency: "EUR", + invoice_number: "LAG & +/#2", + "payment_type[]": ["manual", "provider"], + "payable_type[]": ["Invoice", "PaymentRequest"], + search_term: "pi_3 & +/#", +} satisfies PaymentFilters; + +const { external_customer_id, ...customerFilters } = filters; +customerFilters satisfies CustomerPaymentFilters; + +// These checks fail compilation if generation loses the enum or bigint types. +const invalidStatus: PaymentFilters = { + // @ts-expect-error Unknown statuses must remain a type error. + "payment_status[]": ["bogus"], +}; +const invalidAmount: PaymentFilters = { + // @ts-expect-error Bounds accept decimal strings/numbers, not booleans. + amount_from: true, +}; +void invalidStatus; +void invalidAmount; + +for (const customerScoped of [false, true]) { + Deno.test(`Payment filters serialize without losing precision (customer=${customerScoped})`, async () => { + const route = customerScoped + ? "GET@/api/v1/customers/cust_1/payments" + : "GET@/api/v1/payments"; + const { fetch, getRequest, expectedPath } = createMockFetch( + route, + () => + new Response(JSON.stringify({ + payments: [], + meta: { current_page: 2, total_pages: 0, total_count: 0 }, + })), + ); + const client = Client("test-key", { customFetch: fetch }); + const response = customerScoped + ? await client.customers.findAllCustomerPayments( + external_customer_id, + customerFilters, + ) + : await client.payments.findAllPayments(filters); + assertEquals(response.data.meta.total_count, 0); + const request = getRequest()!; + const url = new URL(request.url); + assertEquals(url.pathname, expectedPath); + assertEquals(request.headers.get("Authorization"), "Bearer test-key"); + const expected = customerScoped ? customerFilters : filters; + for (const [key, value] of Object.entries(expected)) { + assertEquals( + url.searchParams.getAll(key), + (Array.isArray(value) ? value : [value]).map(String), + ); + } + assertEquals([...url.searchParams.keys()].length, customerScoped ? 21 : 22); + assertEquals(url.searchParams.get("amount_from"), "9007199254740993"); + assertEquals(url.searchParams.get("amount_to"), "9223372036854775807"); + }); +} + +Deno.test("Payment filter numeric zero and existing pagination remain supported", async () => { + const { fetch, getRequest } = createMockFetch( + "GET@/api/v1/payments", + () => new Response("{}"), + ); + const client = Client("test-key", { customFetch: fetch }); + await client.payments.findAllPayments({ + page: 1, + per_page: 10, + amount_from: 0, + amount_to: 5000000000, + }); + assertEquals( + new URL(getRequest()!.url).search, + "?page=1&per_page=10&amount_from=0&amount_to=5000000000", + ); +}); diff --git a/tests/plan.test.ts b/tests/plan.test.ts index 5af688c..c598000 100644 --- a/tests/plan.test.ts +++ b/tests/plan.test.ts @@ -1,4 +1,4 @@ -import type { Plan, PlanInput, Plans } from "../mod.ts"; +import type { Plan, PlanCreateInput, PlansPaginated } from "../mod.ts"; import { lagoTest, unprocessableErrorResponse } from "./utils.ts"; const planInput = { @@ -14,7 +14,6 @@ const planInput = { "bill_charges_monthly": false, "charges": [ { - "id": "183da83c-c007-4fbb-afcd-b00c07c41ffe", "billable_metric_id": "278da83c-c007-4fbb-afcd-b00c07c41utg", "charge_model": "standard", "properties": { @@ -27,16 +26,10 @@ const planInput = { }, ], }, - "group_properties": [ - { - "group_id": "123456", - "values": {}, - }, - ], }, ], }, -} satisfies PlanInput; +} satisfies PlanCreateInput; const planResponse = { "plan": { @@ -55,6 +48,13 @@ const planResponse = { { "lago_id": "183da83c-c007-4fbb-afcd-b00c07c41ffe", "lago_billable_metric_id": "278da83c-c007-4fbb-afcd-b00c07c41utg", + "billable_metric_code": "usage", + "pay_in_advance": false, + "invoiceable": true, + "regroup_paid_fees": "invoice", + "prorated": false, + "min_amount_cents": 0, + "filters": [], "created_at": "2022-09-14T16:35:31Z", "charge_model": "standard", "properties": { @@ -67,18 +67,15 @@ const planResponse = { }, ], }, - "group_properties": [ - { - "group_id": "123456", - "values": {}, - }, - ], }, ], }, } satisfies Plan; -const plansResponse = { plans: [planResponse.plan] } satisfies Plans; +const plansResponse = { + meta: { current_page: 1, total_pages: 1, total_count: 1 }, + plans: [planResponse.plan], +} satisfies PlansPaginated; Deno.test("Successfully sent plan responds with 2xx", async (t) => { await lagoTest({ diff --git a/tests/subscription.test.ts b/tests/subscription.test.ts index 4f8a586..ed8afdd 100644 --- a/tests/subscription.test.ts +++ b/tests/subscription.test.ts @@ -1,7 +1,7 @@ import type { Subscription, SubscriptionCreateInput, - Subscriptions, + SubscriptionsPaginated, } from "../mod.ts"; import { lagoTest, unprocessableErrorResponse } from "./utils.ts"; @@ -34,12 +34,19 @@ const subscriptionResponse = { "previous_plan_code": "previous_code", "next_plan_code": "next_code", "downgrade_plan_date": "2022-09-14T16:35:31Z", + "ending_at": null, + "trial_ended_at": null, + "current_billing_period_started_at": null, + "current_billing_period_ending_at": null, + "on_termination_credit_note": "credit", + "on_termination_invoice": "generate", }, } satisfies Subscription; const subscriptionsResponse = { + meta: { current_page: 1, total_pages: 1, total_count: 1 }, subscriptions: [subscriptionResponse.subscription], -} satisfies Subscriptions; +} satisfies SubscriptionsPaginated; Deno.test("Successfully sent subscription responds with 2xx", async (t) => { await lagoTest({ @@ -73,7 +80,9 @@ Deno.test( testType: "200", route: "PUT@/api/v1/subscriptions/id", clientPath: ["subscriptions", "updateSubscription"], - inputParams: ["id", subscriptionInput], + inputParams: ["id", { + subscription: { name: "Updated subscription", ending_at: null }, + }], responseObject: subscriptionResponse, status: 200, }); diff --git a/tests/wallet.test.ts b/tests/wallet.test.ts index 4e0b86d..5d72eb8 100644 --- a/tests/wallet.test.ts +++ b/tests/wallet.test.ts @@ -1,7 +1,7 @@ import type { Wallet, - WalletInput, - Wallets, + WalletCreateInput, + WalletsPaginated, WalletUpdateInput, } from "../mod.ts"; import { lagoTest, unprocessableErrorResponse } from "./utils.ts"; @@ -9,14 +9,14 @@ import { lagoTest, unprocessableErrorResponse } from "./utils.ts"; const walletInput = { "wallet": { "name": "Wallet name", - "rate_amount": 2, + "rate_amount": "2", "currency": "EUR", - "paid_credits": 500, - "granted_credits": 10, + "paid_credits": "500", + "granted_credits": "10", "external_customer_id": "12345", "expiration_at": "2022-09-14T23:59:59Z", }, -} as const satisfies WalletInput; +} as const satisfies WalletCreateInput; const walletResponse = { "wallet": { @@ -26,10 +26,15 @@ const walletResponse = { "status": "active", "currency": "EUR", "name": "Name", - "rate_amount": 2, - "credits_balance": 500, - "balance": 1000, - "consumed_credits": 100, + "rate_amount": "2", + "credits_balance": "500", + "balance_cents": 1000, + "invoice_requires_successful_payment": false, + "ongoing_balance_cents": 1000, + "ongoing_usage_balance_cents": 0, + "credits_ongoing_balance": "500", + "credits_ongoing_usage_balance": "0", + "consumed_credits": "100", "created_at": "2022-09-14T16:35:31Z", "expiration_at": "2022-09-14T23:59:59Z", "last_balance_sync_at": "2022-09-14T16:35:31Z", @@ -46,8 +51,9 @@ const walletUpdateInput = { } as const satisfies WalletUpdateInput; const walletsResponse = { - wallets: [walletInput.wallet], -} satisfies Wallets; + meta: { current_page: 1, total_pages: 1, total_count: 1 }, + wallets: [walletResponse.wallet], +} satisfies WalletsPaginated; Deno.test("Successfully sent wallet responds with 2xx", async (t) => { await lagoTest({ diff --git a/tests/wallet_transaction.test.ts b/tests/wallet_transaction.test.ts index b73c4f0..18c4af8 100644 --- a/tests/wallet_transaction.test.ts +++ b/tests/wallet_transaction.test.ts @@ -1,13 +1,13 @@ -import type { WalletTransaction, WalletTransactionInput } from "../mod.ts"; +import type { WalletTransactionCreateInput } from "../mod.ts"; import { lagoTest, unprocessableErrorResponse } from "./utils.ts"; const walletTransactionInput = { "wallet_transaction": { "wallet_id": "985da83c-c007-4fbb-afcd-b00c07c41ffe", - "paid_credits": 100, - "granted_credits": 10, + "paid_credits": "100", + "granted_credits": "10", }, -} as const satisfies WalletTransactionInput; +} as const satisfies WalletTransactionCreateInput; Deno.test( "Successfully sent wallet transaction responds with 2xx", @@ -22,11 +22,24 @@ Deno.test( wallet_transactions: [ { lago_id: "183da83c-c007-4fbb-afcd-b00c07c41ffe", - lago_wallet_id: "", + lago_wallet_id: "wallet-id", + lago_invoice_id: null, + lago_credit_note_id: null, + lago_voided_invoice_id: null, + source: "manual", + transaction_status: "purchased", + invoice_requires_successful_payment: false, + metadata: [], + remaining_amount_cents: 500, + remaining_credit_amount: "500", + priority: 0, + failed_at: null, + name: null, + payment_method: { payment_method_type: "provider" }, status: "settled", transaction_type: "inbound", - amount: 500, - credit_amount: 500, + amount: "500", + credit_amount: "500", settled_at: "2022-09-14T16:35:31Z", created_at: "2022-09-14T16:35:31Z", },