Skip to content

Commit b06350c

Browse files
authored
Allow shipping-free Tabby checkouts (#15)
1 parent d274eef commit b06350c

10 files changed

Lines changed: 92 additions & 9 deletions

File tree

.changeset/soft-courses-float.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"better-auth-bnpl": patch
3+
---
4+
5+
Allow digital-only Tabby checkouts and order history to omit shipping addresses while retaining Tamara's shipping requirement.

src/__tests__/checkout.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,16 @@ describe("checkout session contract (authenticatedUsersOnly)", () => {
166166
const res = await postCheckout(auth, baseURL, cookie, checkoutBody());
167167
expect(res.status).toBe(200);
168168
});
169+
it("permits checkout without a shipping address", async () => {
170+
const { auth, baseURL, captured } = makeHarness();
171+
const cookie = await signUp(auth, baseURL);
172+
const { shippingAddress: _shippingAddress, ...body } = checkoutBody();
173+
174+
const res = await postCheckout(auth, baseURL, cookie, body);
175+
176+
expect(res.status).toBe(200);
177+
expect(captured.canonical?.shippingAddress).toBeUndefined();
178+
});
169179
});
170180
describe("notification URL basepath", () => {
171181
it("builds the webhook URL under a non-default basePath", async () => {

src/__tests__/providers/tabby/adapter.test.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ const checkoutProviderData = {
8282
} satisfies TabbyCheckoutData;
8383
const checkoutHistoryOrder = checkoutProviderData.order_history[0];
8484
if (!checkoutHistoryOrder) throw new Error("checkout history fixture is missing");
85+
const checkoutShippingAddress = baseCheckout.shippingAddress;
86+
if (!checkoutShippingAddress) throw new Error("checkout shipping fixture is missing");
8587
describe("toTabbyBuyer", () => {
8688
it("preserves a provided buyer name after trimming", () => {
8789
expect(
@@ -117,8 +119,10 @@ describe("toTabbyCheckoutRequest", () => {
117119
expect(req.payment.amount).toBe("500.00");
118120
expect(req.payment.currency).toBe("AED");
119121
expect(req.payment.buyer.name).toBe("Ali Dhamen");
120-
expect(req.payment.shipping_address.address).toBe("Sheikh Zayed Rd");
121-
expect(req.payment.shipping_address.city).toBe("Dubai");
122+
expect(req.payment.shipping_address).toMatchObject({
123+
address: "Sheikh Zayed Rd",
124+
city: "Dubai",
125+
});
122126
expect(req.payment.order.reference_id).toBe("ord-1");
123127
expect(req.payment.order.items[0]?.title).toBe("Keyboard");
124128
expect(req.payment.order.items[0]?.unit_price).toBe("500.00");
@@ -141,11 +145,11 @@ describe("toTabbyCheckoutRequest", () => {
141145
const req = toTabbyCheckoutRequest(
142146
{
143147
...baseCheckout,
144-
shippingAddress: { ...baseCheckout.shippingAddress, line2: "Floor 5" },
148+
shippingAddress: { ...checkoutShippingAddress, line2: "Floor 5" },
145149
},
146150
{ merchantCode: "M" },
147151
);
148-
expect(req.payment.shipping_address.address).toBe("Sheikh Zayed Rd, Floor 5");
152+
expect(req.payment.shipping_address?.address).toBe("Sheikh Zayed Rd, Floor 5");
149153
});
150154
it("validates and maps trusted checkout provider data to Tabby's payment fields", () => {
151155
const req = toTabbyCheckoutRequest(
@@ -165,6 +169,23 @@ describe("toTabbyCheckoutRequest", () => {
165169
content_type: "application/vnd.tabby.v1+json",
166170
});
167171
});
172+
it("accepts order history without shipping_address and omits the key", () => {
173+
const { shipping_address: _shippingAddress, ...historyWithoutShipping } = checkoutHistoryOrder;
174+
const { shippingAddress: _checkoutShippingAddress, ...checkoutWithoutShipping } = baseCheckout;
175+
const req = toTabbyCheckoutRequest(
176+
{
177+
...checkoutWithoutShipping,
178+
providerData: {
179+
...checkoutProviderData,
180+
order_history: [historyWithoutShipping],
181+
},
182+
},
183+
{ merchantCode: "M" },
184+
);
185+
186+
expect(req.payment.order_history?.[0]).not.toHaveProperty("shipping_address");
187+
expect(JSON.stringify(req)).not.toContain('"shipping_address"');
188+
});
168189
it("preserves the previous payment shape when checkout provider data is absent", () => {
169190
const req = toTabbyCheckoutRequest(baseCheckout, { merchantCode: "M" });
170191
expect(req.payment).not.toHaveProperty("buyer_history");

src/__tests__/providers/tabby/factory.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,37 @@ describe("tabby() factory", () => {
384384
expect(result.checkoutUrl).toBe("https://tabby/pay-later");
385385
expect(result.providerOrderId).toBe("payment-1");
386386
});
387+
it("omits shipping_address from the outgoing JSON when shipping is absent", async () => {
388+
let requestBody: unknown;
389+
const fetch: typeof globalThis.fetch = async (_input, init) => {
390+
requestBody = parseRequestBody(init?.body);
391+
return new Response(
392+
JSON.stringify({
393+
id: "checkout-digital",
394+
status: "created",
395+
configuration: {
396+
available_products: {
397+
installments: [{ web_url: "https://tabby/digital" }],
398+
},
399+
},
400+
payment: {
401+
id: "payment-digital",
402+
status: "CREATED",
403+
amount: "100.00",
404+
currency: "SAR",
405+
},
406+
}),
407+
{ status: 200, headers: { "content-type": "application/json" } },
408+
);
409+
};
410+
const provider = tabby({ ...baseConfig, fetch });
411+
const { shippingAddress: _shippingAddress, ...input } = checkoutInput;
412+
413+
await provider.createCheckout(input, ctx);
414+
415+
const parsed = z.object({ payment: z.object({}).passthrough() }).parse(requestBody);
416+
expect(parsed.payment).not.toHaveProperty("shipping_address");
417+
});
387418
});
388419
describe("capture/refund references", () => {
389420
it("requires operation references before calling Tabby", async () => {

src/__tests__/providers/tamara/adapter.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,17 @@ describe("toTamaraCheckoutRequest", () => {
6262
"https://shop.example.com/api/auth/bnpl/webhooks/tamara",
6363
);
6464
});
65+
it("rejects checkout without a shipping address with a typed error", () => {
66+
const { shippingAddress: _shippingAddress, ...input } = baseCheckout;
67+
68+
expect(() => toTamaraCheckoutRequest(input)).toThrowError(
69+
expect.objectContaining({
70+
name: "BnplPluginError",
71+
code: "PROVIDER_NOT_AVAILABLE",
72+
message: "tamara: shippingAddress is required",
73+
}),
74+
);
75+
});
6576
});
6677
describe("tamaraStatusToCanonical", () => {
6778
it("passes through canonical statuses", () => {

src/core/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export interface BnplCheckoutInput {
6262
discount?: BnplDiscount;
6363
items: NonEmptyArray<BnplOrderItem>;
6464
buyer: BnplBuyer;
65-
shippingAddress: BnplAddress;
65+
shippingAddress?: BnplAddress;
6666
billingAddress?: BnplAddress;
6767
countryCode: string;
6868
locale?: BnplLocale;

src/plugins/checkout.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const sharedFields = {
4040
orderReferenceId: z.string().optional(),
4141
description: z.string().max(256),
4242
buyer: buyerSchema.optional(),
43-
shippingAddress: addressSchema,
43+
shippingAddress: addressSchema.optional(),
4444
billingAddress: addressSchema.optional(),
4545
countryCode: z.string().min(2).max(2),
4646
locale: localeSchema.optional(),

src/providers/tabby/adapter.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,9 @@ export function toTabbyCheckoutRequest(
120120
currency: input.totalAmount.currency,
121121
description: input.description,
122122
buyer: toTabbyBuyer(input.buyer),
123-
shipping_address: toTabbyShippingAddress(input.shippingAddress),
123+
...(input.shippingAddress
124+
? { shipping_address: toTabbyShippingAddress(input.shippingAddress) }
125+
: {}),
124126
order: {
125127
reference_id: input.orderReferenceId,
126128
tax_amount: input.taxAmount?.amount,

src/providers/tabby/schemas.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export const tabbyCheckoutCreationOrderHistorySchema = z
102102
payment_method: z.enum(["card", "cod"]).optional(),
103103
status: z.enum(["new", "processing", "complete", "refunded", "canceled", "unknown"]),
104104
buyer: tabbyHistoryBuyerSchema,
105-
shipping_address: tabbyHistoryShippingAddressSchema,
105+
shipping_address: tabbyHistoryShippingAddressSchema.optional(),
106106
items: z.array(tabbyOrderItemHistorySchema).optional(),
107107
})
108108
.strict();
@@ -151,7 +151,7 @@ export const tabbyPaymentRequestSchema = z.object({
151151
currency: nonEmptyStringSchema,
152152
description: z.string().optional(),
153153
buyer: tabbyBuyerRequestSchema,
154-
shipping_address: tabbyShippingAddressRequestSchema,
154+
shipping_address: tabbyShippingAddressRequestSchema.optional(),
155155
order: tabbyOrderRequestSchema,
156156
buyer_history: tabbyBuyerHistoryRequestSchema.optional(),
157157
order_history: z.array(tabbyCheckoutCreationOrderHistorySchema).max(10).optional(),

src/providers/tamara/adapter.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ export function toTamaraCheckoutRequest(
9898
input: BnplCheckoutInput,
9999
opts: ToTamaraCheckoutOptions = {},
100100
): TamaraCheckoutRequest {
101+
if (!input.shippingAddress) {
102+
throw new BnplPluginError("PROVIDER_NOT_AVAILABLE", "tamara: shippingAddress is required");
103+
}
101104
const locale = input.locale ? LOCALE_MAP[input.locale] : opts.defaultLocale;
102105
return {
103106
order_reference_id: input.orderReferenceId,

0 commit comments

Comments
 (0)