diff --git a/sample_code/charge_sample.ts b/sample_code/charge_sample.ts new file mode 100644 index 0000000..a4f5df2 --- /dev/null +++ b/sample_code/charge_sample.ts @@ -0,0 +1,107 @@ +import "https://deno.land/std@0.209.0/dotenv/load.ts"; +import { Client } from "../src/mod.ts"; + +// First, get your API keys from https://portal.vipps.no/ +// Here we assume they are stored in a .env file, see .env.example +const clientId = Deno.env.get("CLIENT_ID") || ""; +const clientSecret = Deno.env.get("CLIENT_SECRET") || ""; + +const merchantSerialNumber = Deno.env.get("MERCHANT_SERIAL_NUMBER") || ""; +const subscriptionKey = Deno.env.get("SUBSCRIPTION_KEY") || ""; + +const customerPhoneNumber = "4791234567"; + +// Create a client +const client = Client({ + merchantSerialNumber, + subscriptionKey, + useTestMode: true, + retryRequests: false, +}); + +// Grab a token +const accessToken = await client.auth.getToken({ + clientId, + clientSecret, + subscriptionKey, +}); + +// Check if the token was retrieved successfully +if (!accessToken.ok) { + console.error("😟 Error retrieving token 😟"); + console.error(accessToken.message); + Deno.exit(1); +} + +const token = accessToken.data.access_token; + +const agreement = await client.agreement.create(token, { + pricing: { + type: "LEGACY", + amount: 2500, + currency: "NOK", + }, + interval: { + unit: "MONTH", + count: 1, + }, + merchantRedirectUrl: "https://example.com/redirect", + merchantAgreementUrl: "https://example.com/agreement", + phoneNumber: customerPhoneNumber, + productName: "MyNews Digital", +}); + +// Check if the agreement was created successfully +if (!agreement.ok) { + console.error("😟 Error creating agreement 😟"); + console.error(agreement.error); + Deno.exit(1); +} + +const agreementId = agreement.data.agreementId; + +const acceptedAgreement = await client.agreement.forceAccept( + token, + agreementId, + { phoneNumber: customerPhoneNumber }, +); + +// Check if the agreement was accepted successfully +if (!acceptedAgreement.ok) { + console.error("😟 Error accepting agreement 😟"); + console.error(acceptedAgreement.error); + Deno.exit(1); +} + +// 10 days from now in YYYY-MM-DD format +const tenDaysFromToday = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000) + .toISOString().split("T")[0]; + +const charge = await client.charge.create(token, agreementId, { + amount: 2500, + description: "MyNews Digital", + orderId: crypto.randomUUID(), + due: tenDaysFromToday, + retryDays: 5, + transactionType: "DIRECT_CAPTURE", +}); + +// Check if the charge was created successfully +if (!charge.ok) { + console.error("😟 Error creating charge 😟"); + console.error(charge.error); + Deno.exit(1); +} + +const chargeId = charge.data.chargeId; + +const chargeInfo = await client.charge.info(token, agreementId, chargeId); + +// Check if the charge info was fetched successfully +if (!chargeInfo.ok) { + console.error("😟 Error retrieving charge 😟"); + console.error(chargeInfo.error); + Deno.exit(1); +} + +console.log(chargeInfo.data); diff --git a/scripts/coverage.ts b/scripts/coverage.ts index 06820b4..f2006c8 100644 --- a/scripts/coverage.ts +++ b/scripts/coverage.ts @@ -59,8 +59,8 @@ if (!branchTotal) { } if (branchTotal > THRESHOLD) { - console.log(`Branch coverage is good: ${branchTotal}`); + console.log(`Branch coverage is good: ${branchTotal}%`); } else { - console.log(`Branch coverage is bad: ${branchTotal}`); + console.log(`Branch coverage is bad: ${branchTotal}%`); Deno.exit(1); } diff --git a/src/apis/agreement.ts b/src/apis/agreement.ts index ae23140..aeb3efe 100644 --- a/src/apis/agreement.ts +++ b/src/apis/agreement.ts @@ -1,6 +1,5 @@ import { RequestData } from "../types.ts"; import { - AgreementErrorResponse, AgreementResponseV3, AgreementStatus, DraftAgreementResponseV3, @@ -8,6 +7,7 @@ import { ForceAcceptAgreementV3, PatchAgreementV3, } from "./types/agreement_types.ts"; +import { RecurringErrorResponse } from "./types/recurring_types.ts"; /** * Factory object for creating and managing agreements. @@ -36,7 +36,7 @@ export const agreementRequestFactory = { create( token: string, body: DraftAgreementV3, - ): RequestData { + ): RequestData { return { url: "/recurring/v3/agreements", method: "POST", @@ -59,7 +59,7 @@ export const agreementRequestFactory = { token: string, status: AgreementStatus, createdAfter: number, - ): RequestData { + ): RequestData { return { url: `/recurring/v3/agreements?status=${status}&createdAfter=${createdAfter}`, @@ -78,7 +78,7 @@ export const agreementRequestFactory = { info( token: string, agreementId: string, - ): RequestData { + ): RequestData { return { url: `/recurring/v3/agreements/${agreementId}`, method: "GET", @@ -99,7 +99,7 @@ export const agreementRequestFactory = { token: string, agreementId: string, body: PatchAgreementV3, - ): RequestData { + ): RequestData { return { url: `/recurring/v3/agreements/${agreementId}`, method: "PATCH", @@ -120,7 +120,7 @@ export const agreementRequestFactory = { token: string, agreementId: string, body: ForceAcceptAgreementV3, - ): RequestData { + ): RequestData { return { url: `/recurring/v3/agreements/${agreementId}/accept`, method: "PATCH", diff --git a/src/apis/charge.ts b/src/apis/charge.ts new file mode 100644 index 0000000..46ed930 --- /dev/null +++ b/src/apis/charge.ts @@ -0,0 +1,167 @@ +import { RequestData } from "../types.ts"; +import { + ChargeReference, + ChargeResponseV3, + ChargeStatus, + CreateChargeV3, + ModifyCharge, +} from "./types/charge_types.ts"; +import { RecurringErrorResponse } from "./types/recurring_types.ts"; + +/** + * Factory object for managing charge API requests. + */ +export const chargeRequestFactory = { + /** + * Creates a new recurring charge (payment) that will charge the user + * on the date specified. If the payment fails, + * the charge will be retried based on retryDays + * + * @param token - The authentication token. + * @param body - The request body containing the charge details. + * @returns A RequestData object containing the URL, method, and token for the API request. + */ + create( + token: string, + agreementId: string, + body: CreateChargeV3, + ): RequestData { + return { + url: `/recurring/v3/agreements/${agreementId}/charges`, + method: "POST", + body, + token, + }; + }, + /** + * Fetches a single charge for a user. + * + * @param token - The authentication token. + * @param chargeId - The ID of the charge to retrieve. + * @param agreementId - The ID of the agreement. + * @returns A RequestData object containing the URL, method, and token for the API request. + */ + info( + token: string, + agreementId: string, + chargeId: string, + ): RequestData { + return { + url: `/recurring/v3/agreements/${agreementId}/charges/${chargeId}`, + method: "GET", + token, + }; + }, + /** + * Retrieves information about a charge by its ID. A "special case" + * endpoint to fetch a single charge just by chargeId, when the + * agreementId is unknown. This is useful for investigating + * claims from customers, but not intended for automation. + * + * Please note: This is not a replacement for the normal endpoint + * for fetching charges + * + * @param token - The access token. + * @param chargeId - The ID of the charge. + * @returns A `RequestData` object containing the URL, method, and token. + */ + infoById( + token: string, + chargeId: string, + ): RequestData { + return { + url: `/recurring/v3/agreements/charges/${chargeId}`, + method: "GET", + token, + }; + }, + /** + * Fetches all charges for a single agreement, including the optional + * initial charge. Supports filtering on status using query parameter. + * + * @param token - The authentication token. + * @param agreementId - The ID of the agreement. + * @returns A RequestData object containing the URL, method, and token for the API request. + */ + list( + token: string, + agreementId: string, + status?: ChargeStatus, + ): RequestData { + const url = status + ? `/recurring/v3/agreements/${agreementId}/charges?status=${status}` + : `/recurring/v3/agreements/${agreementId}/charges`; + + return { url, method: "GET", token }; + }, + /** + * Cancels a pending, due or reserved charge. When cancelling a charge + * that is PARTIALLY_CAPTURED, the remaining funds on the charge + * will be released back to the customer. + * + * Note if you cancel an agreement, there is no need to cancel the + * charges that belongs to the agreement. This will be done automatically. + * + * @param token - The authentication token. + * @param agreementId - The ID of the agreement. + * @param chargeId - The ID of the charge. + * @returns A RequestData object containing the URL, method, and token for the API request. + */ + cancel( + token: string, + agreementId: string, + chargeId: string, + ): RequestData { + return { + url: `/recurring/v3/agreements/${agreementId}/charges/${chargeId}`, + method: "DELETE", + token, + }; + }, + /** + * Captures a reserved charge. Only charges with transactionType + * RESERVE_CAPTURE can be captured. Can also do partial captures + * (captures a smaller part of the payment). + * + * @param token - The authentication token. + * @param agreementId - The ID of the agreement. + * @param chargeId - The ID of the charge. + * @returns A RequestData object containing the URL, method, and token for the API request. + */ + capture( + token: string, + agreementId: string, + chargeId: string, + body: ModifyCharge, + ): RequestData { + return { + url: + `/recurring/v3/agreements/${agreementId}/charges/${chargeId}/capture`, + method: "POST", + body, + token, + }; + }, + /** + * Refunds a charge, can also do a partial refund + * (refunding a smaller part of the payment). + * + * @param token - The authentication token. + * @param agreementId - The ID of the agreement. + * @param chargeId - The ID of the charge. + * @returns A RequestData object containing the URL, method, and token for the API request. + */ + refund( + token: string, + agreementId: string, + chargeId: string, + body: ModifyCharge, + ): RequestData { + return { + url: `/recurring/v3/agreements/${agreementId}/charges/${chargeId}/refund`, + method: "POST", + body, + token, + }; + }, +} as const; diff --git a/src/apis/epayment.ts b/src/apis/epayment.ts index 05bd251..e4c7bdf 100644 --- a/src/apis/epayment.ts +++ b/src/apis/epayment.ts @@ -11,7 +11,7 @@ import { } from "./types/epayment_types.ts"; /** - * Factory object for creating ePayment request data. + * Factory object for creating ePayment API requests. */ export const ePaymentRequestFactory = { /** diff --git a/src/apis/types/agreement_types.ts b/src/apis/types/agreement_types.ts index 10e78fb..ef6b6a6 100644 --- a/src/apis/types/agreement_types.ts +++ b/src/apis/types/agreement_types.ts @@ -1,12 +1,10 @@ //////////////// Common types ///////////////// -/** - * Only NOK is supported at the moment. Support for EUR and DKK will be provided in early 2024. - * @minLength 3 - * @maxLength 3 - * @pattern ^[A-Z]{3}$ - * @example "NOK" - */ -type AgreementCurrencyV3 = "NOK"; + +import { + ChargeType, + RecurringCurrencyV3, + RecurringTransactionType, +} from "./recurring_types.ts"; /** * Status of the agreement. @@ -129,7 +127,7 @@ type AgreementPricingRequest = { */ type?: "LEGACY" | "VARIABLE"; /** Only NOK is supported at the moment. Support for EUR and DKK will be provided in early 2024. */ - currency: AgreementCurrencyV3; + currency: RecurringCurrencyV3; /** * The price of the agreement, required if type is LEGACY or not present. * @@ -177,7 +175,7 @@ type AgreementInitialChargeV3 = { * The type of payment to be made. * @example "DIRECT_CAPTURE" */ - transactionType: "RESERVE_CAPTURE" | "DIRECT_CAPTURE"; + transactionType: RecurringTransactionType; /** * An optional, but recommended `orderId` for the charge. * If provided, this will be the `chargeId` for this charge. @@ -207,7 +205,7 @@ type AgreementTimePeriod = { * Unit for time period * @example "WEEK" */ - unit: "YEAR" | "MONTH" | "WEEK" | "DAY"; + unit: AgreementInterval; /** * Number of units in the time period. Example: unit=week, count=2 to define two weeks * @format int32 @@ -220,6 +218,14 @@ type AgreementTimePeriod = { count: number; }; +/** + * Interval for subscription + * @default "MONTH" + * @pattern ^(YEAR|MONTH|WEEK|DAY)$ + * @example "MONTH" + */ +type AgreementInterval = "YEAR" | "MONTH" | "WEEK" | "DAY"; + export type DraftAgreementResponseV3 = { /** * Id of a an agreement which user may agree to. @@ -371,7 +377,7 @@ type AgreementTimePeriodResponse = { * Unit for time period * @example "WEEK" */ - unit?: "YEAR" | "MONTH" | "WEEK" | "DAY"; + unit?: AgreementInterval; /** * Number of units in the time period. Example: unit=week, count=2 to define two weeks * @format int32 @@ -393,7 +399,7 @@ type AgreementLegacyPricingResponse = { /** The type of pricing. This decides which properties are present. */ type: "LEGACY"; /** ISO-4217: https://www.iso.org/iso-4217-currency-codes.html */ - currency: AgreementCurrencyV3; + currency: RecurringCurrencyV3; /** * The price of the agreement, present if type is LEGACY. * @@ -409,7 +415,7 @@ type AgreementVariableAmountPricingResponse = { /** The type of pricing. This decides which properties are present. */ type: "VARIABLE"; /** ISO-4217: https://www.iso.org/iso-4217-currency-codes.html */ - currency: AgreementCurrencyV3; + currency: RecurringCurrencyV3; /** * The suggested max amount that the customer should choose, present if type is VARIABLE. * @@ -513,12 +519,6 @@ type AgreementPricingUpdateRequest = { suggestedMaxAmount?: number; }; -/** - * @default "RECURRING" - * @example "RECURRING" - */ -type ChargeType = "INITIAL" | "RECURRING"; - export type ForceAcceptAgreementV3 = { /** @example "4791234567" */ phoneNumber: string; @@ -759,78 +759,3 @@ type AgreementLegacyCampaignResponseV3 = { */ explanation?: string; }; - -/////////////// Error responses /////////////// - -export type AgreementErrorResponse = AgreementErrorV3 | AgreementErrorFromAzure; - -/** - * Error response - * Error response using the Problem JSON format - */ -type AgreementErrorV3 = { - /** - * Path to type of error - * @example "https://developer.vippsmobilepay.com/docs/APIs/recurring-api/recurring-api-problems#validation-error" - */ - type?: string; - /** - * Short description of the error - * @example "Bad Request" - */ - title?: string; - /** - * HTTP status returned with the problem - * @format int32 - * @example 400 - */ - status?: number; - /** - * Details about the error - * @example "Input validation failed" - */ - detail?: string; - /** - * The path of the request - * @example "/v3/agreements" - */ - instance?: string; - /** - * An unique ID for the request - * @example "f70b8bf7-c843-4bea-95d9-94725b19895f" - */ - contextId?: string; - extraDetails?: { - /** - * Field to provide additional details on - * @example "productName" - */ - field?: string; - /** - * Details for the error of a specific field - * @example "must not be empty" - */ - text?: string; - }[]; -}; - -/** - * An error from Microsoft Azure. We have limited control of these errors, - * and can not give as detailed information as with the errors from our own code. - * The most important property is the HTTP status code. - */ -type AgreementErrorFromAzure = { - responseInfo: { - /** @example 401 */ - responseCode: number; - /** @example "Unauthorized" */ - responseMessage: string; - }; - result: { - /** - * When possible: A description of what went wrong. - * @example "(An error from Azure API Management, possibly related to authentication)" - */ - message: string; - }; -}; diff --git a/src/apis/types/charge_types.ts b/src/apis/types/charge_types.ts new file mode 100644 index 0000000..5400665 --- /dev/null +++ b/src/apis/types/charge_types.ts @@ -0,0 +1,278 @@ +import { + ChargeType, + RecurringCurrencyV3, + RecurringTransactionType, +} from "./recurring_types.ts"; + +export type CreateChargeV3 = { + /** + * Amount to be paid by the customer. + * + * Amounts are specified in minor units. + * For Norwegian kroner (NOK) that means 1 kr = 100 øre/cents. Example: 499 kr = 49900 øre/cents. + * @format int32 + * @min 100 + * @example 19900 + */ + amount: number; + /** Type of transaction, either direct capture or reserve capture */ + transactionType: RecurringTransactionType; + /** + * This field is visible to the end user in-app + * @min 1 + * @max 45 + * @example "Månedsabonnement" + */ + description: string; + /** + * The date when the charge is due to be processed. + * + * Must be at least two days in advance in the production environment, + * and at least one day in the test environment. + * + * If the charge is `DIRECT_CAPTURE`, the charge is processed and charged on the `due` date. + * If the charge is `RESERVE_CAPTURE`, the charge is `RESERVED` on `due` date. + * + * Must be in the format `YYYY-MM-DD` and ISO 8601. + * @example "2030-12-31" + */ + due: string; + /** + * The service will attempt to charge the customer for the number of days + * specified in `retryDays` after the `due` date. + * We recommend at least two days retry. + * @format int32 + * @min 0 + * @max 14 + * @example 5 + */ + retryDays: number; + /** + * An optional, but recommended `orderId` for the charge. + * If provided, this will be the `chargeId` for this charge. + * This is the unique identifier of the payment, from the payment is initiated and all the way to the settlement data. + * See: https://developer.vippsmobilepay.com/docs/knowledge-base/orderid/ + * If no `orderId` is specified, the `chargeId` will be automatically generated. + * @minLength 1 + * @maxLength 50 + * @pattern ^[a-zA-Z\d-]+ + * @example "acme-shop-123-order123abc" + */ + orderId?: string; + /** + * An optional external ID for the charge, that takes the place of the `orderId` in settlement reports without overriding the default `chargeId` + * The `externalId` can be used by the merchant to map the `chargeId` to an ID in a subscription system or similar. + * Note that while `orderId` must be unique per merchant, `externalId` does not have this limitation, + * so you need to avoid assigning the same `externalId` to multiple charges if you want to keep them separate in settlement reports. + * @minLength 1 + * @maxLength 64 + * @pattern ^.{1,64}$ + * @example "external-id-2468" + */ + externalId?: string; +}; + +export type ChargeReference = { + /** + * Unique identifier for this charge, up to 15 characters. + * @maxLength 15 + * @example "chg_WCVbcAbRCmu2zk" + */ + chargeId: string; +}; + +export type ChargeResponseV3 = { + /** + * Amount to be paid by the customer. + * + * Amounts are specified in minor units. + * For Norwegian kroner (NOK) that means 1 kr = 100 øre. Example: 499 kr = 49900 øre. + * @format int32 + * @example 19900 + */ + amount: number; + /** ISO-4217: https://www.iso.org/iso-4217-currency-codes.html */ + currency: RecurringCurrencyV3; + /** + * Description of the charge + * @example "Premier League subscription: September" + */ + description: string; + /** + * The due date for this charge + * @format date-time + * @example "2019-06-01T00:00:00Z" + */ + due: string; + /** + * Identifier for this charge (for this customer's subscription). + * @maxLength 15 + * @example "chr_WCVbcA" + */ + id: string; + /** + * Id of the agreement the charge belongs to + * @example "agr_5kSeqz" + */ + agreementId: string; + /** + * An optional external ID for the charge + * The `externalId` can be used by the merchant to map the `chargeId` + * to an ID in a subscription system or similar. + * @minLength 1 + * @maxLength 64 + * @pattern ^.{1,64}$ + * @example "external-id-2468" + */ + externalId: string; + /** + * An optional external ID for the agreement + * The `externalId` can be used by the merchant to map the `agreementId` + * to an ID in a subscription system or similar. + * @minLength 1 + * @maxLength 64 + * @pattern ^.{1,64}$ + * @example "external-id-2468" + */ + externalAgreementId?: string; + /** + * The service will attempt to charge the customer for the number of days + * specified in `retryDays` after the `due` date. + * We recommend at least two days retry. + * @format int32 + * @min 0 + * @max 14 + * @example 5 + */ + retryDays: number; + status: ChargeStatus; + /** + * Contains null until the status has reached CHARGED + * @maxLength 36 + * @pattern ^\d{10+}$ + * @example "5001419121" + */ + transactionId: string; + type: ChargeType; + /** Type of transaction, either direct capture or reserve capture */ + transactionType: RecurringTransactionType; + /** + * Identifies the reason why the charged has been marked as `FAILED`: + * * `user_action_required` - The user's card can not fulfil the payment, user needs to take action in the Vipps or MobilePay app. + * Examples: Card is blocked for ecommerce, insufficient funds, expired card. + * + * * `charge_amount_too_high` - The user's max amount is too low, user needs to update their max amount in the Vipps or MobilePay app. + * + * * `non_technical_error` - Something went wrong with charging the user. + * Examples: User has deleted their Vipps MobilePay Profile. + * + * * `technical_error` - Something went wrong in Recurring while performing the payment. + * Examples: Failure in Recurring, failure in downstream services. + * @example "user_action_required" + */ + failureReason?: + | "user_action_required" + | "charge_amount_too_high" + | "non_technical_error" + | "technical_error" + | null; + /** + * Description for the failure reason + * @example "User action required" + */ + failureDescription?: string; + /** A summary of the amounts captured, refunded and cancelled */ + summary: ChargeSummary; + /** List of events related to the charge. */ + history: ChargeHistory; +}; + +/** @example "PENDING" */ +export type ChargeStatus = + | "PENDING" + | "DUE" + | "RESERVED" + | "CHARGED" + | "PARTIALLY_CAPTURED" + | "FAILED" + | "CANCELLED" + | "PARTIALLY_REFUNDED" + | "REFUNDED" + | "PROCESSING"; + +/** A summary of the amounts captured, refunded and cancelled */ +type ChargeSummary = { + /** + * The total amount which has been captured/charged, in case of status charged/partial capture. + * Amounts are specified in minor units. + * For Norwegian kroner (NOK) that means 1 kr = 100 øre. Example: 499 kr = 49900 øre. + * @format int32 + * @example 19900 + */ + captured: number; + /** + * The total amount which has been refunded, in case of status refund/partial refund. + * Amounts are specified in minor units. + * For Norwegian kroner (NOK) that means 1 kr = 100 øre. Example: 499 kr = 49900 øre. + * @format int32 + * @example 0 + */ + refunded: number; + /** + * The total amount which has been cancelled. + * + * Amounts are specified in minor units. + * For Norwegian kroner (NOK) that means 1 kr = 100 øre. Example: 499 kr = 49900 øre. + * @format int32 + * @example 19900 + */ + cancelled: number; +}; + +/** List of events related to the charge. */ +type ChargeHistory = ChargeEvent[]; + +/** Describes the operation that was performed on the charge */ +type ChargeEvent = { + /** + * Date and time of the event, as timestamp on the format `yyyy-MM-dd'T'HH:mm:ss'Z'`, + * with or without milliseconds. + * @format date-time + * @example "2022-09-05T14:25:55Z" + */ + occurred: string; + /** @example "RESERVE" */ + event: "CREATE" | "RESERVE" | "CAPTURE" | "REFUND" | "CANCEL" | "FAIL"; + /** + * The amount related to the operation. + * Amounts are specified in minor units. + * For Norwegian kroner (NOK) that means 1 kr = 100 øre. Example: 499 kr = 49900 øre. + * @format int32 + * @example 19900 + */ + amount: number; + /** The idempotency key of the event */ + idempotencyKey: string; + /** True if the operation was successful, false otherwise */ + success: boolean; +}; + +/** Refund charge request */ +export type ModifyCharge = { + /** + * The amount to refund/capture on a charge. + * + * Amounts are specified in minor units. + * For Norwegian kroner (NOK) that means 1 kr = 100 øre. Example: 499 kr = 49900 øre. + * @format int32 + * @min 100 + * @example 5000 + */ + amount: number; + /** + * A textual description of the operation (refund or capture), which will be displayed in the user's app. + * @min 1 + * @example "'Forgot to apply discount, refunding 50%' or: 'Not all items were in stock. Partial capture.'" + */ + description: string; +}; diff --git a/src/apis/types/recurring_types.ts b/src/apis/types/recurring_types.ts new file mode 100644 index 0000000..a419695 --- /dev/null +++ b/src/apis/types/recurring_types.ts @@ -0,0 +1,95 @@ +/** + * Only NOK is supported at the moment. Support for EUR and DKK will be provided in early 2024. + * @minLength 3 + * @maxLength 3 + * @pattern ^[A-Z]{3}$ + * @example "NOK" + */ +export type RecurringCurrencyV3 = "NOK"; + +/** + * @default "RECURRING" + * @example "RECURRING" + */ +export type ChargeType = "INITIAL" | "RECURRING"; + +/** + * Type of transaction, either direct capture or reserve capture + * @example "DIRECT_CAPTURE" + */ +export type RecurringTransactionType = "DIRECT_CAPTURE" | "RESERVE_CAPTURE"; + +///////////////// Error types ///////////////// + +export type RecurringErrorResponse = RecurringErrorV3 | RecurringErrorFromAzure; + +/** + * Error response + * Error response using the Problem JSON format + */ +type RecurringErrorV3 = { + /** + * Path to type of error + * @example "https://developer.vippsmobilepay.com/docs/APIs/recurring-api/recurring-api-problems#validation-error" + */ + type?: string; + /** + * Short description of the error + * @example "Bad Request" + */ + title?: string; + /** + * HTTP status returned with the problem + * @format int32 + * @example 400 + */ + status?: number; + /** + * Details about the error + * @example "Input validation failed" + */ + detail?: string; + /** + * The path of the request + * @example "/v3/agreements" + */ + instance?: string; + /** + * An unique ID for the request + * @example "f70b8bf7-c843-4bea-95d9-94725b19895f" + */ + contextId?: string; + extraDetails?: { + /** + * Field to provide additional details on + * @example "productName" + */ + field?: string; + /** + * Details for the error of a specific field + * @example "must not be empty" + */ + text?: string; + }[]; +}; + +/** + * An error from Microsoft Azure. We have limited control of these errors, + * and can not give as detailed information as with the errors from our own code. + * The most important property is the HTTP status code. + */ +type RecurringErrorFromAzure = { + responseInfo: { + /** @example 401 */ + responseCode: number; + /** @example "Unauthorized" */ + responseMessage: string; + }; + result: { + /** + * When possible: A description of what went wrong. + * @example "(An error from Azure API Management, possibly related to authentication)" + */ + message: string; + }; +}; diff --git a/src/base_client_helper.ts b/src/base_client_helper.ts index c02d5e4..e0dd3c8 100644 --- a/src/base_client_helper.ts +++ b/src/base_client_helper.ts @@ -81,7 +81,7 @@ export const buildRequest = ( ...requestData.headers, ...{ "Content-Type": "application/json", - "Authorization": `Bearer ${requestData.token}` || "", + "Authorization": `Bearer ${requestData.token || ""}`, "User-Agent": getUserAgent(), "Ocp-Apim-Subscription-Key": cfg.subscriptionKey, "Merchant-Serial-Number": cfg.merchantSerialNumber, diff --git a/src/mod.ts b/src/mod.ts index 71023f1..723e61c 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -6,6 +6,7 @@ import { ePaymentRequestFactory } from "./apis/epayment.ts"; import { webhooksRequestFactory } from "./apis/webhooks.ts"; import { checkoutRequestFactory } from "./apis/checkout.ts"; import { agreementRequestFactory } from "./apis/agreement.ts"; +import { chargeRequestFactory } from "./apis/charge.ts"; /** * Creates a client with the specified options. @@ -23,6 +24,7 @@ export const Client = (options: ClientConfig) => { webhook: createApi(client, webhooksRequestFactory), checkout: createApi(client, checkoutRequestFactory), agreement: createApi(client, agreementRequestFactory), + charge: createApi(client, chargeRequestFactory), } satisfies APIClient; return apiClient; diff --git a/src/types.ts b/src/types.ts index ca92bf7..499faf3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,7 +45,7 @@ export type Credentials = { } & Pick; export type RequestData = { - method: string; + method: "GET" | "POST" | "PATCH" | "DELETE"; url: string; headers?: HeadersInit; body?: unknown; diff --git a/tests/agreement_test.ts b/tests/agreement_test.ts index edfa592..ef0811f 100644 --- a/tests/agreement_test.ts +++ b/tests/agreement_test.ts @@ -1,5 +1,6 @@ import { assertEquals, mf } from "./test_deps.ts"; import { Client } from "../src/mod.ts"; +import { agreementRequestFactory } from "../src/apis/agreement.ts"; Deno.test("agreements - create - check correct url in TEST/MT", async () => { mf.install(); @@ -40,3 +41,64 @@ Deno.test("agreements - create - check correct url in TEST/MT", async () => { mf.reset(); }); + +Deno.test("list - should return the correct RequestData object", () => { + const token = "your-auth-token"; + const status = "ACTIVE"; + const createdAfter = 1628764800; + + const requestData = agreementRequestFactory.list(token, status, createdAfter); + + assertEquals( + requestData.url, + `/recurring/v3/agreements?status=${status}&createdAfter=${createdAfter}`, + ); + assertEquals(requestData.method, "GET"); +}); + +Deno.test("info - should return the correct RequestData object", () => { + const token = "your-auth-token"; + const agreementId = "your-agreement-id"; + + const requestData = agreementRequestFactory.info(token, agreementId); + + assertEquals( + requestData.url, + `/recurring/v3/agreements/${agreementId}`, + ); + assertEquals(requestData.method, "GET"); +}); + +Deno.test("update - should return the correct RequestData object", () => { + const token = "your-auth-token"; + const agreementId = "your-agreement-id"; + const body = { pricing: { amount: 1000, suggestedMaxAmount: 10000 } }; + + const requestData = agreementRequestFactory.update(token, agreementId, body); + + assertEquals( + requestData.url, + `/recurring/v3/agreements/${agreementId}`, + ); + assertEquals(requestData.method, "PATCH"); + assertEquals(requestData.body, body); +}); + +Deno.test("forceAccept - should return the correct RequestData object", () => { + const token = "your-auth-token"; + const agreementId = "your-agreement-id"; + const body = { phoneNumber: "4791234567" }; + + const requestData = agreementRequestFactory.forceAccept( + token, + agreementId, + body, + ); + + assertEquals( + requestData.url, + `/recurring/v3/agreements/${agreementId}/accept`, + ); + assertEquals(requestData.method, "PATCH"); + assertEquals(requestData.body, body); +}); diff --git a/tests/base_client_helper_test.ts b/tests/base_client_helper_test.ts index 6336bcf..9ff0aaf 100644 --- a/tests/base_client_helper_test.ts +++ b/tests/base_client_helper_test.ts @@ -91,6 +91,50 @@ Deno.test("buildRequest - Should return a Request object with the correct proper assert(checkHeaderKeys); }); +Deno.test("buildRequest - Should return a Request object when filling in missing properties", () => { + const cfg: ClientConfig = { + subscriptionKey: "your-subscription-key", + merchantSerialNumber: "your-merchant-serial-number", + }; + + const requestData: RequestData = { + method: "GET", + url: "/your-endpoint", + }; + + const expectedBaseURL = "https://api.vipps.no"; + const expectedReqInit = { + method: "GET", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer", + "User-Agent": "Vipps/Deno SDK/local", + "Ocp-Apim-Subscription-Key": "your-subscription-key", + "Merchant-Serial-Number": "your-merchant-serial-number", + "Vipps-System-Name": "", + "Vipps-System-Version": "", + "Vipps-System-Plugin-Name": "", + "Vipps-System-Plugin-Version": "", + "Idempotency-Key": "your-random-uuid", + }, + body: JSON.stringify({ key: "value" }), + }; + + const request = buildRequest(cfg, requestData); + + assertEquals(request.url, `${expectedBaseURL}${requestData.url}`); + assertEquals(request.method, expectedReqInit.method); + assertEquals( + request.headers.get("Authorization"), + expectedReqInit.headers.Authorization, + ); + + const checkHeaderKeys = Object.keys(expectedReqInit.headers).every((key) => + request.headers.has(key) + ); + assert(checkHeaderKeys); +}); + Deno.test("createUserAgent - Should return the correct user agent string when loaded from deno.land/x", () => { const expectedUserAgent = "Vipps/Deno SDK/1.0.0"; const actualUserAgent = createSDKUserAgent( diff --git a/tests/charge_test.ts b/tests/charge_test.ts new file mode 100644 index 0000000..e4c7692 --- /dev/null +++ b/tests/charge_test.ts @@ -0,0 +1,124 @@ +import { assert } from "./test_deps.ts"; +import { chargeRequestFactory } from "../src/apis/charge.ts"; + +Deno.test("create - should return the correct RequestData object", () => { + const token = "your-auth-token"; + const agreementId = "your-agreement-id"; + + const requestData = chargeRequestFactory.create(token, agreementId, { + amount: 1000, + transactionType: "DIRECT_CAPTURE", + description: "Test charge", + due: "2030-12-31", + retryDays: 5, + externalId: "test-charge-123", + orderId: "test-order-123", + }); + + assert(requestData.url, `/recurring/v3/agreements/${agreementId}/charges`); + assert(requestData.method, "POST"); +}); + +Deno.test("info - should return the correct RequestData object", () => { + const token = "your-auth-token"; + const agreementId = "your-agreement-id"; + const chargeId = "your-charge-id"; + + const requestData = chargeRequestFactory.info(token, agreementId, chargeId); + + assert( + requestData.url, + `/recurring/v3/agreements/${agreementId}/charges/${chargeId}`, + ); + assert(requestData.method, "GET"); +}); + +Deno.test("infoById should return the correct RequestData object", () => { + const token = "your-access-token"; + const chargeId = "your-charge-id"; + + const requestData = chargeRequestFactory.infoById(token, chargeId); + + assert(requestData.url, "/recurring/v3/agreements/charges/your-charge-id"); + assert(requestData.method, "GET"); +}); + +Deno.test("list should return the correct RequestData object", () => { + const token = "your-access-token"; + const agreementId = "your-agreement-id"; + const status = "CHARGED"; + + const requestData = chargeRequestFactory.list(token, agreementId, status); + + assert( + requestData.url, + "/recurring/v3/agreements/your-agreement-id/charges?status=CHARGED", + ); + assert(requestData.method, "GET"); +}); + +Deno.test("list should return the correct RequestData object without search query", () => { + const token = "your-access-token"; + const agreementId = "your-agreement-id"; + + const requestData = chargeRequestFactory.list(token, agreementId); + + assert( + requestData.url, + "/recurring/v3/agreements/your-agreement-id/charges", + ); +}); + +Deno.test("cancel should return the correct RequestData object", () => { + const token = "your-access-token"; + const agreementId = "your-agreement-id"; + const chargeId = "your-charge-id"; + + const requestData = chargeRequestFactory.cancel(token, agreementId, chargeId); + + assert( + requestData.url, + "/recurring/v3/agreements/your-agreement-id/charges/your-charge-id", + ); + assert(requestData.method, "DELETE"); +}); + +Deno.test("capture should return the correct RequestData object", () => { + const token = "your-access-token"; + const agreementId = "your-agreement-id"; + const chargeId = "your-charge-id"; + const body = { amount: 1000, description: "Test charge" }; + + const requestData = chargeRequestFactory.capture( + token, + agreementId, + chargeId, + body, + ); + + assert( + requestData.url, + "/recurring/v3/agreements/your-agreement-id/charges/your-charge-id/capture", + ); + assert(requestData.method, "POST"); +}); + +Deno.test("refund should return the correct RequestData object", () => { + const token = "your-access-token"; + const agreementId = "your-agreement-id"; + const chargeId = "your-charge-id"; + const body = { amount: 1000, description: "Test charge" }; + + const requestData = chargeRequestFactory.refund( + token, + agreementId, + chargeId, + body, + ); + + assert( + requestData.url, + "/recurring/v3/agreements/your-agreement-id/charges/your-charge-id/refund", + ); + assert(requestData.method, "POST"); +}); diff --git a/tests/error_test.ts b/tests/error_test.ts index 243c0d9..42a42f7 100644 --- a/tests/error_test.ts +++ b/tests/error_test.ts @@ -28,6 +28,16 @@ Deno.test("parseError - Should return correct error message for generic Error", assertEquals(result.message, `${error.name} - ${error.message}`); }); +Deno.test("parseError should return correct error message for forbidden Error", () => { + const error = new Error("Forbidden"); + const result = parseError(error); + assertEquals(result.ok, false); + assertEquals( + result.message, + "Your credentials are not authorized for this product, please visit portal.vipps.no", + ); +}); + Deno.test("parseError - Should return correct error message for AccessTokenError", () => { const error: AccessTokenError = { error: "access_token_error", diff --git a/tests/mod_test.ts b/tests/mod_test.ts index 52290c1..92b5249 100644 --- a/tests/mod_test.ts +++ b/tests/mod_test.ts @@ -22,4 +22,11 @@ Deno.test("Client - available functions", () => { assertEquals(typeof client.agreement.list, "function"); assertEquals(typeof client.agreement.update, "function"); assertEquals(typeof client.agreement.forceAccept, "function"); + assertEquals(typeof client.charge.create, "function"); + assertEquals(typeof client.charge.info, "function"); + assertEquals(typeof client.charge.infoById, "function"); + assertEquals(typeof client.charge.list, "function"); + assertEquals(typeof client.charge.cancel, "function"); + assertEquals(typeof client.charge.capture, "function"); + assertEquals(typeof client.charge.refund, "function"); });