diff --git a/package.json b/package.json index a9a98ba..fa8cce9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stripe-stateful-mock", - "version": "0.0.10", + "version": "0.0.11", "description": "A half-baked, stateful Stripe mock server", "main": "dist/index.js", "scripts": { diff --git a/src/api/AccountData.ts b/src/api/AccountData.ts index 8c0652e..5f4027b 100644 --- a/src/api/AccountData.ts +++ b/src/api/AccountData.ts @@ -1,4 +1,4 @@ -export class AccountData { +export class AccountData { private data: {[accountId: string]: {[id: string]: T}} = {}; @@ -12,7 +12,9 @@ export class AccountData { if (!this.data[accountId]) { return []; } - return Object.keys(this.data[accountId]).map(key => this.data[accountId][key]); + return Object.keys(this.data[accountId]) + .map(key => this.data[accountId][key]) + .sort((a, b) => b.created - a.created); } contains(accountId: string, objectId: string): boolean { diff --git a/src/api/accounts.ts b/src/api/accounts.ts index c89eb7d..6d10539 100644 --- a/src/api/accounts.ts +++ b/src/api/accounts.ts @@ -1,6 +1,6 @@ import * as stripe from "stripe"; import log = require("loglevel"); -import {generateId} from "./utils"; +import {applyListOptions, generateId} from "./utils"; import {StripeError} from "./StripeError"; export namespace accounts { @@ -27,7 +27,6 @@ export namespace accounts { } const connectedAccountId = (params as any).id || `acct_${generateId(16)}`; - const now = new Date(); const account: stripe.accounts.IAccount & any = { // The d.ts is out of date on this object and I don't want to bother. id: connectedAccountId, object: "account", @@ -45,7 +44,7 @@ export namespace accounts { capabilities: {}, charges_enabled: false, country: params.country || "US", - created: (now.getTime() / 1000) | 0, + created: (Date.now() / 1000) | 0, default_currency: params.default_currency || "usd", details_submitted: false, email: params.email || "site@stripe.com", @@ -169,6 +168,11 @@ export namespace accounts { return accounts[connectedAccountId]; } + export function list(accountId: string, params: stripe.IListOptions): stripe.IList { + let data = Object.values(accounts); + return applyListOptions(data, params, (id, paramName) => retrieve(accountId, id, paramName)); + } + export function del(accountId: string, connectedAccountId: string): stripe.IDeleteConfirmation { log.debug("accounts.delete", accountId, connectedAccountId); diff --git a/src/api/charges.ts b/src/api/charges.ts index 20a2b3a..c391797 100644 --- a/src/api/charges.ts +++ b/src/api/charges.ts @@ -1,12 +1,13 @@ import * as stripe from "stripe"; import log = require("loglevel"); import {StripeError} from "./StripeError"; -import {generateId, stringifyMetadata} from "./utils"; +import {applyListOptions, generateId, stringifyMetadata} from "./utils"; import {getEffectiveSourceTokenFromChain, isSourceTokenChain} from "./sourceTokenChains"; import {cards} from "./cards"; import {AccountData} from "./AccountData"; import {customers} from "./customers"; import {disputes} from "./disputes"; +import {refunds} from "./refunds"; export namespace charges { @@ -156,6 +157,14 @@ export namespace charges { return charge; } + export function list(accountId: string, params: stripe.charges.IChargeListOptions): stripe.IList { + let data = accountCharges.getAll(accountId); + if (params.customer) { + data = data.filter(d => d.customer === params.customer); + } + return applyListOptions(data, params, (id, paramName) => retrieve(accountId, id, paramName)); + } + export function update(accountId: string, chargeId: string, params: stripe.charges.IChargeUpdateOptions): stripe.charges.ICharge { log.debug("charges.update", accountId, chargeId, params); @@ -224,7 +233,7 @@ export namespace charges { if (captureAmount < charge.amount) { charge.captured = true; - createRefund(accountId, { + refunds.create(accountId, { amount: charge.amount - captureAmount, charge: charge.id }); @@ -236,137 +245,7 @@ export namespace charges { return charge; } - export function createRefund(accountId: string, params: stripe.refunds.IRefundCreationOptionsWithCharge): stripe.refunds.IRefund { - log.debug("charges.createRefund", accountId, params); - - if (params.hasOwnProperty("amount")) { - if (params.amount < 1) { - throw new StripeError(400, { - code: "parameter_invalid_integer", - doc_url: "https://stripe.com/docs/error-codes/parameter-invalid-integer", - message: "Invalid positive integer", - param: "amount", - type: "invalid_request_error" - }); - } - if (params.amount > 99999999) { - throw new StripeError(400, { - code: "amount_too_large", - doc_url: "https://stripe.com/docs/error-codes/amount-too-large", - message: "Amount must be no more than $999,999.99", - param: "amount", - type: "invalid_request_error" - }); - } - } - - const charge = retrieve(accountId, params.charge, "id"); - if (charge.amount_refunded >= charge.amount) { - throw new StripeError(400, { - code: "charge_already_refunded", - doc_url: "https://stripe.com/docs/error-codes/charge-already-refunded", - message: `Charge ${charge.id} has already been refunded.`, - type: "invalid_request_error" - }); - } - if (charge.dispute) { - const dispute = disputes.retrieve(accountId, charge.dispute as string, "dispute"); - if (!dispute.is_charge_refundable) { - throw new StripeError(400, { - code: "charge_disputed", - doc_url: "https://stripe.com/docs/error-codes/charge-disputed", - message: `Charge ${charge.id} has been charged back; cannot issue a refund.`, - type: "invalid_request_error" - }); - } - } - - let refundAmount = params.hasOwnProperty("amount") ? +params.amount : charge.amount - charge.amount_refunded; - if (refundAmount > charge.amount - charge.amount_refunded) { - throw new StripeError(400, { - message: `Refund amount (\$${refundAmount / 100}) is greater than unrefunded amount on charge (\$${(charge.amount - charge.amount_refunded) / 100})`, - param: "amount", - type: "invalid_request_error" - }); - } - - if (!charge.captured && charge.amount !== refundAmount) { - throw new StripeError(400, { - message: "You cannot partially refund an uncaptured charge. Instead, capture the charge for an amount less than the original amount", - param: "amount", - type: "invalid_request_error" - }); - } - - const now = new Date(); - const refund: stripe.refunds.IRefund = { - id: "re_" + generateId(24), - object: "refund", - amount: refundAmount, - balance_transaction: "txn_" + generateId(24), - charge: charge.id, - created: (now.getTime() / 1000) | 0, - currency: charge.currency.toLowerCase(), - metadata: stringifyMetadata(params.metadata), - reason: params.reason || null, - receipt_number: null, - source_transfer_reversal: null, - status: "succeeded", - transfer_reversal: null - }; - charge.refunds.data.unshift(refund); - charge.refunds.total_count++; - charge.amount_refunded += refundAmount; - charge.refunded = charge.amount_refunded === charge.amount; - return refund; - } - - export function retrieveRefund(accountId: string, refundId: string, paramName: string): stripe.refunds.IRefund { - log.debug("charges.retrieveRefund", accountId, refundId); - - for (const charge of accountCharges.getAll(accountId)) { - const refund = charge.refunds.data.find(refund => refund.id === refundId); - if (refund) { - return refund; - } - } - - throw new StripeError(404, { - code: "resource_missing", - doc_url: "https://stripe.com/docs/error-codes/resource-missing", - message: `No such refund: ${refundId}`, - param: paramName, - type: "invalid_request_error" - }); - } - - export function listRefunds(accountId: string, params: stripe.refunds.IRefundListOptions): stripe.IList { - if (params.charge) { - return listChargeRefunds(accountId, params.charge, params); - } - - log.debug("charges.listRefunds", accountId, params); - const refunds: stripe.refunds.IRefund[] = accountCharges.getAll(accountId).reduce((refunds, charge) => [...refunds, ...charge.refunds.data], [] as stripe.refunds.IRefund[]); - return { - object: "list", - data: refunds, - has_more: false, - url: "/v1/refunds", - total_count: refunds.length - }; - } - - export function listChargeRefunds(accountId: string, chargeId: string, params: stripe.IListOptions): stripe.IList { - log.debug("charges.listChargeRefunds", accountId, chargeId, params); - const charge = retrieve(accountId, chargeId, "charge"); - return { - ...charge.refunds, - total_count: undefined // For some reason this isn't on this endpoint. - }; - } - function getChargeFromCard(params: stripe.charges.IChargeCreationOptions, source: stripe.cards.ICard): stripe.charges.ICharge { - const now = new Date(); const chargeId = "ch_" + generateId(); return { id: chargeId, @@ -391,7 +270,7 @@ export namespace charges { phone: null }, captured: params.capture as any !== "false", - created: (now.getTime() / 1000) | 0, + created: (Date.now() / 1000) | 0, currency: params.currency.toLowerCase(), customer: null, description: params.description || null, diff --git a/src/api/customers.ts b/src/api/customers.ts index 758ccde..7c108f3 100644 --- a/src/api/customers.ts +++ b/src/api/customers.ts @@ -1,7 +1,7 @@ import * as stripe from "stripe"; import log = require("loglevel"); import {StripeError} from "./StripeError"; -import {generateId, stringifyMetadata} from "./utils"; +import {applyListOptions, generateId, stringifyMetadata} from "./utils"; import {cards} from "./cards"; import {AccountData} from "./AccountData"; @@ -22,14 +22,13 @@ export namespace customers { } const customerId = (params as any).id || `cus_${generateId(14)}`; - const now = new Date(); const customer: stripe.customers.ICustomer = { id: customerId, object: "customer", account_balance: +params.account_balance || +params.balance || 0, address: params.address || null, balance: +params.balance || +params.account_balance || 0, - created: (now.getTime() / 1000) | 0, + created: (Date.now() / 1000) | 0, currency: null, default_source: null, delinquent: false, @@ -101,6 +100,14 @@ export namespace customers { return customer; } + export function list(accountId: string, params: stripe.customers.ICustomerListOptions): stripe.IList { + let data = accountCustomers.getAll(accountId); + if (params.email) { + data = data.filter(d => d.email === params.email); + } + return applyListOptions(data, params, (id, paramName) => retrieve(accountId, id, paramName)); + } + export function update(accountId: string, customerId: string, params: stripe.customers.ICustomerUpdateOptions) { log.debug("customers.update", accountId, customerId, params); diff --git a/src/api/disputes.ts b/src/api/disputes.ts index 2878285..1ea2692 100644 --- a/src/api/disputes.ts +++ b/src/api/disputes.ts @@ -21,7 +21,6 @@ export namespace disputes { transactionAvailableDate.setHours(17); transactionAvailableDate.setMinutes(0, 0, -1); - const now = new Date(); const disputeId = `dp_${generateId(24)}`; const disputeTxnId = `txn_${generateId(24)}`; const dispute: stripe.disputes.IDispute = { @@ -34,7 +33,7 @@ export namespace disputes { object: "balance_transaction", amount: -charge.amount, available_on: (transactionAvailableDate.getTime() / 1000) | 0, - created: (now.getTime() / 1000) | 0, + created: (Date.now() / 1000) | 0, currency: charge.currency, description: `Chargeback withdrawal for ${charge.id}`, exchange_rate: null, @@ -55,7 +54,7 @@ export namespace disputes { } ], charge: charge.id, - created: (now.getTime() / 1000) | 0, + created: (Date.now() / 1000) | 0, currency: charge.currency, evidence: { access_activity_log: null, diff --git a/src/api/idempotency.ts b/src/api/idempotency.ts index 5ca94a9..bcce0a5 100644 --- a/src/api/idempotency.ts +++ b/src/api/idempotency.ts @@ -8,6 +8,7 @@ import {getRequestAccountId} from "../routes"; interface StoredRequest { id: string; + created: number; requestId: string; requestBody: any; responseCode: number; @@ -46,6 +47,7 @@ export function idempotencyRoute(req: express.Request, res: express.Response, ne } else { const storedRequest: StoredRequest = { id: storedRequestKey, + created: (Date.now() / 1000) | 0, requestId: "req_" + generateId(14), requestBody: req.body, responseCode: 0, diff --git a/src/api/refunds.ts b/src/api/refunds.ts new file mode 100644 index 0000000..f6e849d --- /dev/null +++ b/src/api/refunds.ts @@ -0,0 +1,123 @@ +import * as stripe from "stripe"; +import log = require("loglevel"); +import {AccountData} from "./AccountData"; +import {StripeError} from "./StripeError"; +import {disputes} from "./disputes"; +import {applyListOptions, generateId, stringifyMetadata} from "./utils"; +import {charges} from "./charges"; + +export namespace refunds { + + const accountRefunds = new AccountData(); + + export function create(accountId: string, params: stripe.refunds.IRefundCreationOptionsWithCharge): stripe.refunds.IRefund { + log.debug("refunds.create", accountId, params); + + if (params.hasOwnProperty("amount")) { + if (params.amount < 1) { + throw new StripeError(400, { + code: "parameter_invalid_integer", + doc_url: "https://stripe.com/docs/error-codes/parameter-invalid-integer", + message: "Invalid positive integer", + param: "amount", + type: "invalid_request_error" + }); + } + if (params.amount > 99999999) { + throw new StripeError(400, { + code: "amount_too_large", + doc_url: "https://stripe.com/docs/error-codes/amount-too-large", + message: "Amount must be no more than $999,999.99", + param: "amount", + type: "invalid_request_error" + }); + } + } + + const charge = charges.retrieve(accountId, params.charge, "id"); + if (charge.amount_refunded >= charge.amount) { + throw new StripeError(400, { + code: "charge_already_refunded", + doc_url: "https://stripe.com/docs/error-codes/charge-already-refunded", + message: `Charge ${charge.id} has already been refunded.`, + type: "invalid_request_error" + }); + } + if (charge.dispute) { + const dispute = disputes.retrieve(accountId, charge.dispute as string, "dispute"); + if (!dispute.is_charge_refundable) { + throw new StripeError(400, { + code: "charge_disputed", + doc_url: "https://stripe.com/docs/error-codes/charge-disputed", + message: `Charge ${charge.id} has been charged back; cannot issue a refund.`, + type: "invalid_request_error" + }); + } + } + + let refundAmount = params.hasOwnProperty("amount") ? +params.amount : charge.amount - charge.amount_refunded; + if (refundAmount > charge.amount - charge.amount_refunded) { + throw new StripeError(400, { + message: `Refund amount (\$${refundAmount / 100}) is greater than unrefunded amount on charge (\$${(charge.amount - charge.amount_refunded) / 100})`, + param: "amount", + type: "invalid_request_error" + }); + } + + if (!charge.captured && charge.amount !== refundAmount) { + throw new StripeError(400, { + message: "You cannot partially refund an uncaptured charge. Instead, capture the charge for an amount less than the original amount", + param: "amount", + type: "invalid_request_error" + }); + } + + const refund: stripe.refunds.IRefund = { + id: "re_" + generateId(24), + object: "refund", + amount: refundAmount, + balance_transaction: "txn_" + generateId(24), + charge: charge.id, + created: (Date.now() / 1000) | 0, + currency: charge.currency.toLowerCase(), + metadata: stringifyMetadata(params.metadata), + reason: params.reason || null, + receipt_number: null, + source_transfer_reversal: null, + status: "succeeded", + transfer_reversal: null + }; + charge.refunds.data.unshift(refund); + charge.refunds.total_count++; + charge.amount_refunded += refundAmount; + charge.refunded = charge.amount_refunded === charge.amount; + accountRefunds.put(accountId, refund); + return refund; + } + + export function retrieve(accountId: string, refundId: string, paramName: string): stripe.refunds.IRefund { + log.debug("refunds.retrieve", accountId, refundId); + + const refund = accountRefunds.get(accountId, refundId); + if (!refund) { + throw new StripeError(404, { + code: "resource_missing", + doc_url: "https://stripe.com/docs/error-codes/resource-missing", + message: `No such refund: ${refundId}`, + param: paramName, + type: "invalid_request_error" + }); + } + return refund; + } + + export function list(accountId: string, params: stripe.refunds.IRefundListOptions): stripe.IList { + log.debug("refunds.list", accountId, params); + + let data: stripe.refunds.IRefund[] = accountRefunds.getAll(accountId); + if (params.charge) { + data = data.filter(d => d.charge === params.charge); + } + return applyListOptions(data, params, (id, paramName) => retrieve(accountId, id, paramName)); + } +} diff --git a/src/api/utils.ts b/src/api/utils.ts index 70a4000..b0a6594 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -1,3 +1,5 @@ +import * as stripe from "stripe"; + export function generateId(length: number = 20): string { const chars = "0123456789abcfedghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; return new Array(length).fill("5").map(() => chars[(Math.random() * chars.length) | 0]).join(""); @@ -14,3 +16,33 @@ export function stringifyMetadata(metadata?: {[key: string]: string | number}): } return resp; } + +export function applyListOptions(data: T[], params: stripe.IListOptions, retriever: (id: string, paramName: string) => T): stripe.IList { + let hasMore = false; + if (params.starting_after) { + const startingAfter = retriever(params.starting_after, "starting_after"); + const startingAfterIx = data.indexOf(startingAfter); + data = data.slice(startingAfterIx + 1); + if (params.limit && data.length > params.limit) { + data = data.slice(0, params.limit); + hasMore = true; + } + } else if (params.ending_before) { + const endingBefore = retriever(params.ending_before, "ending_before"); + const endingBeforeIx = data.indexOf(endingBefore); + data = data.slice(0, endingBeforeIx); + if (params.limit && data.length > params.limit) { + data = data.slice(data.length - params.limit); + hasMore = true; + } + } else if (params.limit && data.length > params.limit) { + data = data.slice(0, params.limit); + hasMore = true; + } + return { + object: "list", + data: data, + has_more: hasMore, + url: "/v1/refunds" + }; +} diff --git a/src/routes.ts b/src/routes.ts index aa784df..78364e5 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -4,6 +4,7 @@ import {accounts} from "./api/accounts"; import {charges} from "./api/charges"; import {customers} from "./api/customers"; import {disputes} from "./api/disputes"; +import {refunds} from "./api/refunds"; const routes = express.Router(); @@ -18,6 +19,11 @@ routes.post("/v1/accounts", (req, res) => { return res.status(200).json(account); }); +routes.get("/v1/accounts", (req, res) => { + const accountList = accounts.list(getRequestAccountId(req), req.query); + return res.status(200).json(accountList); +}); + routes.get("/v1/accounts/:id", (req, res) => { accounts.retrieve("acct_default", req.params.id, auth.getCensoredAccessTokenFromRequest(req)); const account = accounts.retrieve(getRequestAccountId(req), req.params.id, auth.getCensoredAccessTokenFromRequest(req)); @@ -30,6 +36,11 @@ routes.delete("/v1/accounts/:id", (req, res) => { return res.status(200).json(account); }); +routes.get("/v1/charges", (req, res) => { + const chargeList = charges.list(getRequestAccountId(req), req.query); + return res.status(200).json(chargeList); +}); + routes.post("/v1/charges", (req, res) => { const charge = charges.create(getRequestAccountId(req), req.body); return res.status(200).json(charge); @@ -52,8 +63,8 @@ routes.post("/v1/charges/:id/capture", (req, res) => { // Old API. routes.get("/v1/charges/:id/refunds", (req, res) => { - const refunds = charges.listChargeRefunds(getRequestAccountId(req), req.params.id, req.body); - return res.status(200).json(refunds); + const refundList = refunds.list(getRequestAccountId(req), {...req.query, charge: req.params.id}); + return res.status(200).json(refundList); }); routes.post("/v1/customers", (req, res) => { @@ -61,6 +72,11 @@ routes.post("/v1/customers", (req, res) => { return res.status(200).json(customer); }); +routes.get("/v1/customers", (req, res) => { + const customerList = customers.list(getRequestAccountId(req), req.query); + return res.status(200).json(customerList); +}); + routes.get("/v1/customers/:id", (req, res) => { const customer = customers.retrieve(getRequestAccountId(req), req.params.id, "id"); return res.status(200).json(customer); @@ -99,17 +115,17 @@ routes.get("/v1/disputes/:id", (req, res) => { }); routes.post("/v1/refunds", (req, res) => { - const refund = charges.createRefund(getRequestAccountId(req), req.body); + const refund = refunds.create(getRequestAccountId(req), req.body); return res.status(200).json(refund); }); routes.get("/v1/refunds", (req, res) => { - const refunds = charges.listRefunds(getRequestAccountId(req), req.body); - return res.status(200).json(refunds); + const refundList = refunds.list(getRequestAccountId(req), req.query); + return res.status(200).json(refundList); }); routes.get("/v1/refunds/:id", (req, res) => { - const refund = charges.retrieveRefund(getRequestAccountId(req), req.params.id, "id"); + const refund = refunds.retrieve(getRequestAccountId(req), req.params.id, "id"); return res.status(200).json(refund); }); diff --git a/test/accounts.ts b/test/accounts.ts index 006cfe7..0a3e68a 100644 --- a/test/accounts.ts +++ b/test/accounts.ts @@ -127,4 +127,13 @@ describe("accounts", () => { chai.assert.equal(delError.rawType, "invalid_request_error"); chai.assert.equal(delError.type, "StripePermissionError"); }); + + it("can list accounts", async () => { + const listStart = await localStripeClient.accounts.list(); + + const anotherAccount = await localStripeClient.accounts.create({type: "custom"}); + const listOneMore = await localStripeClient.accounts.list(); + chai.assert.lengthOf(listOneMore.data, listStart.data.length + 1); + chai.assert.deepInclude(listOneMore.data, anotherAccount); + }); }); diff --git a/test/buildStripeParityTest.ts b/test/buildStripeParityTest.ts index df8f6a0..fee1a36 100644 --- a/test/buildStripeParityTest.ts +++ b/test/buildStripeParityTest.ts @@ -18,7 +18,7 @@ export function buildStripeParityTest { } )); + it("can list charges", async () => { + // Create a fresh account to get a clean slate. + const account = await localStripeClient.accounts.create({type: "custom"}); + + const listEmpty = await localStripeClient.customers.list({stripe_account: account.id}); + chai.assert.lengthOf(listEmpty.data, 0); + + const charge0 = await localStripeClient.charges.create({ + amount: 1234, + currency: "usd", + source: "tok_visa" + }, {stripe_account: account.id}); + const listOne = await localStripeClient.charges.list({stripe_account: account.id}); + chai.assert.lengthOf(listOne.data, 1); + chai.assert.sameDeepMembers(listOne.data, [charge0]); + + const charge1 = await localStripeClient.charges.create({ + amount: 5678, + currency: "usd", + source: "tok_visa" + }, {stripe_account: account.id}); + const listTwo = await localStripeClient.charges.list({stripe_account: account.id}); + chai.assert.lengthOf(listTwo.data, 2); + chai.assert.sameDeepMembers(listTwo.data, [charge1, charge0]); + + const listLimit1 = await localStripeClient.charges.list({limit: 1}, {stripe_account: account.id}); + chai.assert.lengthOf(listLimit1.data, 1); + + const listLimit2 = await localStripeClient.charges.list({limit: 1, starting_after: listLimit1.data[0].id}, {stripe_account: account.id}); + chai.assert.lengthOf(listLimit1.data, 1); + chai.assert.sameDeepMembers([...listLimit2.data, ...listLimit1.data], listTwo.data); + }); + describe("unofficial token support", () => { describe("tok_429", () => { it("throws a 429 error", async () => { @@ -765,184 +798,6 @@ describe("charges", () => { )); }); - describe("refund", () => { - it("can refund a whole charge", buildStripeParityTest( - async stripeClient => { - const charge = await stripeClient.charges.create({ - amount: 4300, - currency: "usd", - source: "tok_visa", - }); - const refund = await stripeClient.refunds.create({ - charge: charge.id - }); - const refundedCharge = await stripeClient.charges.retrieve(charge.id); - return [charge, refund, refundedCharge]; - } - )); - - it("can partial refund a charge", buildStripeParityTest( - async stripeClient => { - const charge = await stripeClient.charges.create({ - amount: 4300, - currency: "usd", - source: "tok_visa", - }); - const refund = await stripeClient.refunds.create({ - charge: charge.id, - amount: 1200 - }); - const refundedCharge = await stripeClient.charges.retrieve(charge.id); - return [charge, refund, refundedCharge]; - } - )); - - it("can partial refund then whole refund a charge", buildStripeParityTest( - async stripeClient => { - const charge = await stripeClient.charges.create({ - amount: 4300, - currency: "usd", - source: "tok_visa", - }); - const refund1 = await stripeClient.refunds.create({ - charge: charge.id, - amount: 1200, - metadata: { - extra: "info" - } - }); - const refund2 = await stripeClient.refunds.create({ - charge: charge.id, - metadata: { - extra: "even more info" - } - }); - const refundedCharge = await stripeClient.charges.retrieve(charge.id); - return [charge, refund1, refund2, refundedCharge]; - } - )); - - it("can refund a charge with metadata and a reason", buildStripeParityTest( - async stripeClient => { - const charge = await stripeClient.charges.create({ - amount: 4300, - currency: "usd", - source: "tok_visa", - }); - const refund = await stripeClient.refunds.create({ - charge: charge.id, - reason: "fraudulent", - metadata: { - extra: "info" - } - }); - const refundedCharge = await stripeClient.charges.retrieve(charge.id); - return [charge, refund, refundedCharge]; - } - )); - - it("can refund a captured charge", buildStripeParityTest( - async stripeClient => { - const charge = await stripeClient.charges.create({ - amount: 4300, - currency: "usd", - source: "tok_visa", - capture: false - }); - const capture = await stripeClient.charges.capture(charge.id, {amount: 1300}); - const refund = await stripeClient.refunds.create({ - charge: charge.id, - reason: "fraudulent", - metadata: { - extra: "info" - } - }); - const refundedCharge = await stripeClient.charges.retrieve(charge.id); - return [charge, capture, refund, refundedCharge]; - } - )); - - it("can't refund a non-existent charge", buildStripeParityTest( - async stripeClient => { - let refundError: any = null; - try { - await stripeClient.refunds.create({charge: generateId()}); - } catch (err) { - refundError = err; - } - return [refundError]; - } - )); - - it("can't refund more than the amount on the charge", buildStripeParityTest( - async stripeClient => { - const charge = await stripeClient.charges.create({ - amount: 4300, - currency: "usd", - source: "tok_visa" - }); - - let refundError: any = null; - try { - await stripeClient.refunds.create({charge: charge.id, amount: 4500}); - } catch (err) { - refundError = err; - } - return [charge, refundError]; - } - )); - - it("can't refund an already refunded charge", buildStripeParityTest( - async stripeClient => { - const charge = await stripeClient.charges.create({ - amount: 4300, - currency: "usd", - source: "tok_visa" - }); - - const refund = await stripeClient.refunds.create({charge: charge.id}); - - let refundError: any = null; - try { - await stripeClient.refunds.create({charge: charge.id}); - } catch (err) { - refundError = err; - } - return [charge, refund, refundError]; - } - )); - - it("can't refund a disputed charge with is_charge_refundable=false", buildStripeParityTest( - async stripeClient => { - const charge = await stripeClient.charges.create({ - amount: 4300, - currency: "usd", - source: "tok_createDispute" - }); - - let refundError: any = null; - try { - await stripeClient.refunds.create({charge: charge.id}); - } catch (err) { - refundError = err; - } - return [charge, refundError]; - } - )); - - it("can refund a disputed charge with is_charge_refundable=true", buildStripeParityTest( - async stripeClient => { - const charge = await stripeClient.charges.create({ - amount: 4300, - currency: "usd", - source: "tok_createDisputeInquiry" - }); - const refund = await stripeClient.refunds.create({charge: charge.id}); - return [charge, refund]; - } - )); - }); - describe("update", () => { it("can update metadata", buildStripeParityTest( async stripeClient => { diff --git a/test/customers.ts b/test/customers.ts index 5c2530a..1312f08 100644 --- a/test/customers.ts +++ b/test/customers.ts @@ -205,6 +205,31 @@ describe("customers", () => { } )); + it("can list customers", async () => { + // Create a fresh account to get a clean slate. + const account = await localStripeClient.accounts.create({type: "custom"}); + + const listEmpty = await localStripeClient.customers.list({stripe_account: account.id}); + chai.assert.lengthOf(listEmpty.data, 0); + + const customer0 = await localStripeClient.customers.create({email: "luser0@example.com"}, {stripe_account: account.id}); + const listOne = await localStripeClient.customers.list({stripe_account: account.id}); + chai.assert.lengthOf(listOne.data, 1); + chai.assert.sameDeepMembers(listOne.data, [customer0]); + + const charge1 = await localStripeClient.customers.create({email: "luser1@example.com"}, {stripe_account: account.id}); + const listTwo = await localStripeClient.customers.list({stripe_account: account.id}); + chai.assert.lengthOf(listTwo.data, 2); + chai.assert.sameDeepMembers(listTwo.data, [charge1, customer0]); + + const listLimit1 = await localStripeClient.customers.list({limit: 1}, {stripe_account: account.id}); + chai.assert.lengthOf(listLimit1.data, 1); + + const listLimit2 = await localStripeClient.customers.list({limit: 1, starting_after: listLimit1.data[0].id}, {stripe_account: account.id}); + chai.assert.lengthOf(listLimit1.data, 1); + chai.assert.sameDeepMembers([...listLimit2.data, ...listLimit1.data], listTwo.data); + }); + describe("unofficial token support", () => { describe("tok_forget", () => { it("forgets the customer when specified on customer create", async () => { diff --git a/test/refunds.ts b/test/refunds.ts new file mode 100644 index 0000000..9bce3c1 --- /dev/null +++ b/test/refunds.ts @@ -0,0 +1,221 @@ +import chaiExclude from "chai-exclude"; +import {getLocalStripeClient} from "./stripeUtils"; +import {generateId} from "../src/api/utils"; +import {buildStripeParityTest} from "./buildStripeParityTest"; +import chai = require("chai"); + +chai.use(chaiExclude); + +describe("refunds", () => { + + const localStripeClient = getLocalStripeClient(); + + it("can refund a whole charge", buildStripeParityTest( + async stripeClient => { + const charge = await stripeClient.charges.create({ + amount: 4300, + currency: "usd", + source: "tok_visa", + }); + const refund = await stripeClient.refunds.create({ + charge: charge.id + }); + const refundedCharge = await stripeClient.charges.retrieve(charge.id); + const retrievedRefund = await stripeClient.refunds.retrieve(refund.id); + return [charge, refund, refundedCharge, retrievedRefund]; + } + )); + + it("can partial refund a charge", buildStripeParityTest( + async stripeClient => { + const charge = await stripeClient.charges.create({ + amount: 4300, + currency: "usd", + source: "tok_visa", + }); + const refund = await stripeClient.refunds.create({ + charge: charge.id, + amount: 1200 + }); + const refundedCharge = await stripeClient.charges.retrieve(charge.id); + return [charge, refund, refundedCharge]; + } + )); + + it("can partial refund then whole refund a charge", buildStripeParityTest( + async stripeClient => { + const charge = await stripeClient.charges.create({ + amount: 4300, + currency: "usd", + source: "tok_visa", + }); + const refund1 = await stripeClient.refunds.create({ + charge: charge.id, + amount: 1200, + metadata: { + extra: "info" + } + }); + const refund2 = await stripeClient.refunds.create({ + charge: charge.id, + metadata: { + extra: "even more info" + } + }); + const refundedCharge = await stripeClient.charges.retrieve(charge.id); + return [charge, refund1, refund2, refundedCharge]; + } + )); + + it("can refund a charge with metadata and a reason", buildStripeParityTest( + async stripeClient => { + const charge = await stripeClient.charges.create({ + amount: 4300, + currency: "usd", + source: "tok_visa", + }); + const refund = await stripeClient.refunds.create({ + charge: charge.id, + reason: "fraudulent", + metadata: { + extra: "info" + } + }); + const refundedCharge = await stripeClient.charges.retrieve(charge.id); + return [charge, refund, refundedCharge]; + } + )); + + it("can refund a captured charge", buildStripeParityTest( + async stripeClient => { + const charge = await stripeClient.charges.create({ + amount: 4300, + currency: "usd", + source: "tok_visa", + capture: false + }); + const capture = await stripeClient.charges.capture(charge.id, {amount: 1300}); + const refund = await stripeClient.refunds.create({ + charge: charge.id, + reason: "fraudulent", + metadata: { + extra: "info" + } + }); + const refundedCharge = await stripeClient.charges.retrieve(charge.id); + return [charge, capture, refund, refundedCharge]; + } + )); + + it("can list refunds by charge", async () => { + const charge = await localStripeClient.charges.create({ + amount: 900, + currency: "usd", + source: "tok_visa" + }); + + const unrelatedCharge = await localStripeClient.charges.create({ + amount: 1250, + currency: "usd", + source: "tok_visa" + }); + await localStripeClient.refunds.create({charge: unrelatedCharge.id}); + + const refund1 = await localStripeClient.refunds.create({charge: charge.id, amount: 420}); + chai.assert.equal(refund1.charge, charge.id); + const refunds1 = await localStripeClient.refunds.list({charge: charge.id}); + chai.assert.deepEqual(refunds1.data, [refund1]); + + const refund2 = await localStripeClient.refunds.create({charge: charge.id}); + chai.assert.equal(refund2.charge, charge.id); + const refunds2 = await localStripeClient.refunds.list({charge: charge.id}); + chai.assert.sameDeepMembers(refunds2.data, [refund2, refund1]); + + // Should be sorted newest to oldest but the test comes too quickly to be able to sort. + const refundsLimited1 = await localStripeClient.refunds.list({charge: charge.id, limit: 1}); + chai.assert.lengthOf(refundsLimited1.data, 1); + const refundsLimited2 = await localStripeClient.refunds.list({charge: charge.id, limit: 1, starting_after: refundsLimited1.data[0].id}); + chai.assert.lengthOf(refundsLimited2.data, 1); + chai.assert.sameDeepMembers([...refundsLimited1.data, ...refundsLimited2.data], refunds2.data); + }); + + it("can't refund a non-existent charge", buildStripeParityTest( + async stripeClient => { + let refundError: any = null; + try { + await stripeClient.refunds.create({charge: generateId()}); + } catch (err) { + refundError = err; + } + return [refundError]; + } + )); + + it("can't refund more than the amount on the charge", buildStripeParityTest( + async stripeClient => { + const charge = await stripeClient.charges.create({ + amount: 4300, + currency: "usd", + source: "tok_visa" + }); + + let refundError: any = null; + try { + await stripeClient.refunds.create({charge: charge.id, amount: 4500}); + } catch (err) { + refundError = err; + } + return [charge, refundError]; + } + )); + + it("can't refund an already refunded charge", buildStripeParityTest( + async stripeClient => { + const charge = await stripeClient.charges.create({ + amount: 4300, + currency: "usd", + source: "tok_visa" + }); + + const refund = await stripeClient.refunds.create({charge: charge.id}); + + let refundError: any = null; + try { + await stripeClient.refunds.create({charge: charge.id}); + } catch (err) { + refundError = err; + } + return [charge, refund, refundError]; + } + )); + + it("can't refund a disputed charge with is_charge_refundable=false", buildStripeParityTest( + async stripeClient => { + const charge = await stripeClient.charges.create({ + amount: 4300, + currency: "usd", + source: "tok_createDispute" + }); + + let refundError: any = null; + try { + await stripeClient.refunds.create({charge: charge.id}); + } catch (err) { + refundError = err; + } + return [charge, refundError]; + } + )); + + it("can refund a disputed charge with is_charge_refundable=true", buildStripeParityTest( + async stripeClient => { + const charge = await stripeClient.charges.create({ + amount: 4300, + currency: "usd", + source: "tok_createDisputeInquiry" + }); + const refund = await stripeClient.refunds.create({charge: charge.id}); + return [charge, refund]; + } + )); +});