Skip to content
Merged
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
120 changes: 117 additions & 3 deletions packages/targets/payment-square/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,122 @@
import { describe, it, expect } from 'vitest';
import { fakeBuildContext, makeVault } from '@profullstack/sh1pt-core/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import target from './index.js';

describe('payment-square', () => {
it('should have correct metadata', () => {
describe('payment-square target adapter', () => {
beforeEach(() => {
vi.clearAllMocks();
});

afterEach(() => {
vi.unstubAllGlobals();
});

it('has correct package metadata', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const pkg = require('../package.json');
expect(pkg.name).toBe('@profullstack/sh1pt-target-payment-square');
});

it('creates payments with validated amount, currency, and sourceId', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ payment: { id: 'pay_123' } }),
});
vi.stubGlobal('fetch', fetchMock);

const result = await target.build(fakeBuildContext({
secret: makeVault({
SQUARE_ACCESS_TOKEN: 'square-token',
SQUARE_LOCATION_ID: 'loc_123',
}),
}) as any, {
command: 'create',
args: { amount: 1500, currency: 'usd', sourceId: 'cnon:card-nonce' },
});

expect(result).toEqual({
artifact: 'square-payment-create',
meta: { raw: JSON.stringify({ payment: { id: 'pay_123' } }) },
});
expect(fetchMock).toHaveBeenCalledWith('https://connect.squareup.com/v2/payments', expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer square-token',
'Content-Type': 'application/json',
}),
}));
const body = JSON.parse(String(fetchMock.mock.calls[0]![1].body));
expect(body).toMatchObject({
source_id: 'cnon:card-nonce',
amount_money: { amount: 1500, currency: 'USD' },
location_id: 'loc_123',
});
});

it('rejects invalid create amounts before calling Square', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);

await expect(target.build(fakeBuildContext({
secret: makeVault({ SQUARE_ACCESS_TOKEN: 'square-token' }),
}) as any, {
command: 'create',
args: { amount: 1.5, currency: 'USD', sourceId: 'cnon:card-nonce' },
})).rejects.toThrow('amount must be a positive integer');

expect(fetchMock).not.toHaveBeenCalled();
});

it('rejects invalid currency codes before calling Square', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);

await expect(target.build(fakeBuildContext({
secret: makeVault({ SQUARE_ACCESS_TOKEN: 'square-token' }),
}) as any, {
command: 'create',
args: { amount: 1500, currency: 'US', sourceId: 'cnon:card-nonce' },
})).rejects.toThrow('currency must be a three-letter ISO code');

expect(fetchMock).not.toHaveBeenCalled();
});

it('requires a sourceId for create commands', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);

await expect(target.build(fakeBuildContext({
secret: makeVault({ SQUARE_ACCESS_TOKEN: 'square-token' }),
}) as any, {
command: 'create',
args: { amount: 1500, currency: 'USD', sourceId: ' ' },
})).rejects.toThrow('sourceId required');

expect(fetchMock).not.toHaveBeenCalled();
});

it('requires payment IDs for get, cancel, and refund commands', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const ctx = fakeBuildContext({
secret: makeVault({ SQUARE_ACCESS_TOKEN: 'square-token' }),
}) as any;

await expect(target.build(ctx, {
command: 'get',
args: { paymentId: ' ' },
})).rejects.toThrow('paymentId required');

await expect(target.build(ctx, {
command: 'cancel',
args: {},
})).rejects.toThrow('paymentId required');

await expect(target.build(ctx, {
command: 'refund',
args: {},
})).rejects.toThrow('paymentId required');

expect(fetchMock).not.toHaveBeenCalled();
});
});
45 changes: 32 additions & 13 deletions packages/targets/payment-square/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ interface SquareError {
errors?: Array<{ code: string; detail: string }>;
}

function requireText(value: unknown, name: string): string {
if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} required`);
return value.trim();
}

function requirePositiveInteger(value: unknown, name: string): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer`);
}
return value;
}

function requireCurrency(value: unknown): string {
const currency = typeof value === 'string' ? value.trim().toUpperCase() : 'USD';
if (!/^[A-Z]{3}$/.test(currency)) throw new Error('currency must be a three-letter ISO code');
return currency;
}
Comment on lines +25 to +29

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 requireCurrency silently defaults instead of rejecting

When value is not a string — e.g. undefined, null, or a number — the function substitutes 'USD' and passes the ^[A-Z]{3}$ check without throwing. Every other require* helper in this file throws on an invalid/missing input, so callers expecting a validation error when they forget to supply currency (or pass the wrong type) will silently receive a USD charge instead. The inconsistency also means the function name "require" is misleading; a missing currency is accepted rather than rejected.


export default defineTarget<Config>({
id: 'payment-square',
kind: 'payment',
Expand Down Expand Up @@ -41,9 +59,9 @@ export default defineTarget<Config>({

switch (cmd) {
case 'create': {
const amount = config.args?.amount as number || 0;
const currency = config.args?.currency as string || 'USD';
const sourceId = config.args?.sourceId as string || '';
const amount = requirePositiveInteger(config.args?.amount, 'amount');
const currency = requireCurrency(config.args?.currency);
const sourceId = requireText(config.args?.sourceId, 'sourceId');
ctx.log(`square: creating payment of ${amount} ${currency}`);
const data = await sq('/payments', {
method: 'POST',
Expand All @@ -54,27 +72,27 @@ export default defineTarget<Config>({
location_id: location,
}),
});
return { output: JSON.stringify(data) };
return { artifact: 'square-payment-create', meta: { raw: JSON.stringify(data) } };
}
case 'get': {
const id = config.args?.paymentId as string || '';
const id = requireText(config.args?.paymentId, 'paymentId');
ctx.log(`square: getting payment ${id}`);
const data = await sq(`/payments/${id}`);
return { output: JSON.stringify(data) };
return { artifact: 'square-payment-get', meta: { raw: JSON.stringify(data) } };
}
case 'cancel': {
const id = config.args?.paymentId as string || '';
const id = requireText(config.args?.paymentId, 'paymentId');
ctx.log(`square: canceling payment ${id}`);
const data = await sq(`/payments/${id}/cancel`, { method: 'POST' });
return { output: JSON.stringify(data) };
return { artifact: 'square-payment-cancel', meta: { raw: JSON.stringify(data) } };
}
case 'list': {
ctx.log('square: listing payments');
const data = await sq('/payments');
return { output: JSON.stringify(data) };
return { artifact: 'square-payment-list', meta: { raw: JSON.stringify(data) } };
}
case 'refund': {
const id = config.args?.paymentId as string || '';
const id = requireText(config.args?.paymentId, 'paymentId');
ctx.log(`square: refunding payment ${id}`);
const data = await sq('/refunds', {
method: 'POST',
Expand All @@ -85,7 +103,7 @@ export default defineTarget<Config>({
reason: (config.args?.reason as string) || 'requested_by_customer',
}),
});
Comment on lines 97 to 105

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Refund amount_money sends a bare number instead of a { amount, currency } object

Square's refund endpoint expects amount_money to be an object shaped { amount: number, currency: string }. The current code passes config.args?.amount directly — a bare integer like 1500 — so any partial-refund request will fail with a Square API validation error. When amount is omitted the field becomes undefined, which JSON.stringify drops entirely (triggering a full refund), so the bug surfaces only for partial refunds. Currency is also never threaded through to the refund body.

Suggested change
const data = await sq('/refunds', {
method: 'POST',
body: JSON.stringify({
idempotency_key: `sh1pt-${Date.now()}`,
payment_id: id,
...(config.args?.amount !== undefined && {
amount_money: {
amount: requirePositiveInteger(config.args.amount, 'amount'),
currency: requireCurrency(config.args?.currency),
},
}),
reason: (config.args?.reason as string) || 'requested_by_customer',
}),
});

return { output: JSON.stringify(data) };
return { artifact: 'square-payment-refund', meta: { raw: JSON.stringify(data) } };
}
default:
throw new Error(`Unknown command: ${cmd}`);
Expand All @@ -96,7 +114,7 @@ export default defineTarget<Config>({
ctx.log('square: verifying setup');
const key = ctx.secret('SQUARE_ACCESS_TOKEN');
if (!key) {
return setupGuide({
const setup = setupGuide({
title: 'Square Access Token',
steps: [
'1. Go to https://developer.squareup.com/apps',
Expand All @@ -106,7 +124,8 @@ export default defineTarget<Config>({
'5. Run: sh1pt secret set SQUARE_ACCESS_TOKEN <token>',
],
});
return { id: 'setup-required', meta: { setup } };
}
return { status: 'ready' };
return { id: 'ready', meta: { status: 'ready' } };
},
});
Loading