feat(transfer): generate invoice for given transfer
MODIFY
src/integration/billing/BillingIntegration.ts
src/integration/billing/BillingIntegration.ts
@@ -710,6 +710,8 @@ export default abstract class BillingIntegration {
abstract createSubAccount(): Promise<BillingAccount>;
+ abstract generateTransferInvoice(transfer: BillingTransfer, user: User): Promise<BillingInvoice>;
+
protected async triggerTransferPreparation(billingInvoice: BillingInvoice): Promise<void> {
if (!billingInvoice.sessions) {
// This should not happen!
@@ -768,6 +770,7 @@ export default abstract class BillingIntegration {
transfer = {
accountID, status: BillingTransferStatus.DRAFT, sessions: [], totalAmount: 0, transferAmount: 0,
platformFeeData: null, transferExternalID: null,
+ currency: invoice.currency
};
}
// Process all sessions of the invoice matching the current account ID
@@ -806,5 +809,4 @@ export default abstract class BillingIntegration {
});
}
}
-
}

MODIFY
src/integration/billing/stripe/StripeBillingIntegration.ts
src/integration/billing/stripe/StripeBillingIntegration.ts
import { AsyncTaskType, AsyncTasks } from '../../../types/AsyncTask';
/* eslint-disable @typescript-eslint/member-ordering */
-import { BillingAccount, BillingAccountStatus, BillingDataTransactionStart, BillingDataTransactionStop, BillingDataTransactionUpdate, BillingInvoice, BillingInvoiceItem, BillingInvoiceStatus, BillingOperationResult, BillingPaymentMethod, BillingSessionAccountData, BillingStatus, BillingTax, BillingUser, BillingUserData } from '../../../types/Billing';
+import { BillingAccount, BillingAccountStatus, BillingDataTransactionStart, BillingDataTransactionStop, BillingDataTransactionUpdate, BillingInvoice, BillingInvoiceItem, BillingInvoiceStatus, BillingOperationResult, BillingPaymentMethod, BillingSessionAccountData, BillingStatus, BillingTax, BillingTransfer, BillingUser, BillingUserData } from '../../../types/Billing';
import { DimensionType, PricedConsumptionData, PricedDimensionData } from '../../../types/Pricing';
import FeatureToggles, { Feature } from '../../../utils/FeatureToggles';
import StripeHelpers, { StripeChargeOperationResult } from './StripeHelpers';
@@ -287,7 +287,7 @@ export default class StripeBillingIntegration extends BillingIntegration {
metadata: {
tenantID: this.tenant.id,
userID
- }
+ },
}, {
// idempotency_key: idempotencyKey?.toString(),
idempotencyKey: idempotencyKey?.toString(), // STRIPE version 8.137.0 - property as been renamed!!!
@@ -1741,4 +1741,52 @@ export default class StripeBillingIntegration extends BillingIntegration {
status: BillingAccountStatus.IDLE
};
}
+
+ public async generateTransferInvoice(billingTransfer: BillingTransfer, user: User): Promise<BillingInvoice> {
+ await this.checkConnection();
+ // Create invoice items
+ try {
+ await Promise.all(billingTransfer.sessions.map(async (session) =>
+ this.createStripeInvoiceItem({
+ amount: session.amount * 100,
+ customer: user.billingData.customerID,
+ currency: billingTransfer.currency
+ }, this.buildIdemPotencyKey(session.transactionID, 'invoice'))
+ ));
+ } catch (e) {
+ throw new BackendError({
+ message: 'Unexpected situation - unable to create invoice item',
+ detailedMessages: { e },
+ module: MODULE_NAME, action: ServerAction.BILLING_TRANSFER_FINALIZE,
+ method: 'generateTransferInvoice',
+ });
+ }
+ // Create invoice
+ let stripeInvoice: Stripe.Invoice;
+ try {
+ stripeInvoice = await this.createStripeInvoice(user.billingData.customerID, user.id, this.buildIdemPotencyKey(billingTransfer.id, 'invoice'));
+ } catch (e) {
+ throw new BackendError({
+ message: 'Unexpected situation - unable to create transfer invoice',
+ detailedMessages: { e },
+ module: MODULE_NAME, action: ServerAction.BILLING_TRANSFER_FINALIZE,
+ method: 'generateTransferInvoice',
+ });
+ }
+ // Do not charge the user
+ try {
+ stripeInvoice = await this.stripe.invoices.pay(stripeInvoice.id, {
+ paid_out_of_band: true
+ });
+ } catch (e) {
+ throw new BackendError({
+ message: 'Unexpected situation - unable to flag the invoice as paid out of band',
+ detailedMessages: { e },
+ module: MODULE_NAME, action: ServerAction.BILLING_TRANSFER_FINALIZE,
+ method: 'generateTransferInvoice',
+ });
+ }
+ const invoice = this.convertToBillingInvoice(stripeInvoice);
+ return invoice;
+ }
}

MODIFY
src/server/rest/v1/service/BillingService.ts
src/server/rest/v1/service/BillingService.ts
@@ -640,6 +640,18 @@ export default class BillingService {
const filteredRequest = BillingValidatorRest.getInstance().validateBillingTransferFinalizeReq(req.params);
// Check authorization
await AuthorizationService.checkAndGetBillingTransfersAuthorizations(req.tenant, req.user, Action.BILLING_FINALIZE_TRANSFER);
+ // Get the billing implementation
+ const billingImpl = await BillingFactory.getBillingImpl(req.tenant);
+ if (!billingImpl) {
+ throw new AppError({
+ errorCode: HTTPError.GENERAL_ERROR,
+ message: 'Billing service is not configured',
+ module: MODULE_NAME, method: 'handleFinalizeTransfer',
+ action: action,
+ user: req.user
+ });
+ }
+ // Get the transfer
const transfer = await BillingStorage.getTransferByID(req.tenant, filteredRequest.ID);
UtilsService.assertObjectExists(action, transfer, `Transfer ID '${filteredRequest.ID}' does not exist`, MODULE_NAME, 'handleFinalizeTransfer', req.user);
// Check if the transfer is in draft status
@@ -654,7 +666,13 @@ export default class BillingService {
}
transfer.status = BillingTransferStatus.FINALIZED;
await BillingStorage.saveTransfer(req.tenant, transfer);
- // TODO Generate an invoice for the transfer
+ // Generate the invoice
+ const subAccount = await BillingStorage.getSubAccountByID(req.tenant, transfer.accountID);
+ UtilsService.assertObjectExists(action, subAccount, `Sub account ID '${transfer.accountID}' does not exist`, MODULE_NAME, 'handleFinalizeTransfer', req.user);
+ const user = await UserStorage.getUser(req.tenant, subAccount.businessOwnerID);
+ UtilsService.assertObjectExists(action, user, `User ID '${transfer.accountID}' does not exist`, MODULE_NAME, 'handleFinalizeTransfer', req.user);
+ user.billingData = (await billingImpl.forceSynchronizeUser(user)).billingData;
+ const invoice = await billingImpl.generateTransferInvoice(transfer, user);
res.json(Constants.REST_RESPONSE_SUCCESS);
next();
}

MODIFY
src/storage/mongodb/BillingStorage.ts
src/storage/mongodb/BillingStorage.ts
@@ -354,7 +354,8 @@ export default class BillingStorage {
amount: session.amount,
roundedAmount: session.roundedAmount,
platformFeeStrategy: session.platformFeeStrategy,
- }))
+ })),
+ currency: transfer.currency,
};
if (transfer.platformFeeData) {
transferMDB.platformFeeData = {

MODIFY
src/types/Billing.ts
src/types/Billing.ts
@@ -204,6 +204,7 @@ export interface BillingTransfer extends CreatedUpdatedProps, BillingTransferAut
businessOwner?: User;
platformFeeData: BillingPlatformFeeData;
transferExternalID: string; // Transfer sent to the CPO
+ currency: string;
}
// Very important - preserve maximal precision - Decimal type is persisted as an object in the DB

MODIFY
test/api/BillingTest.ts
test/api/BillingTest.ts
/* eslint-disable max-len */
-import { BillingAccount, BillingAccountStatus, BillingChargeInvoiceAction, BillingInvoiceStatus, BillingTransferStatus } from '../../src/types/Billing';
+import { BillingAccount, BillingAccountStatus, BillingChargeInvoiceAction, BillingInvoiceStatus, BillingTransfer, BillingTransferStatus } from '../../src/types/Billing';
import { BillingPlatformFeeStrategyFactory, BillingTransferFactory } from '../factories/BillingFactory';
import { BillingSettings, BillingSettingsType } from '../../src/types/Setting';
import chai, { expect } from 'chai';
@@ -115,7 +115,6 @@ describeif(isBillingProperlyConfigured)('Billing', () => {
it('should be able to repair a user', async () => {
await stripeTestHelper.checkRepairInconsistencies();
});
-
});
});
@@ -1241,6 +1240,27 @@ describeif(isBillingProperlyConfigured)('Billing', () => {
const finalizeResponse = await billingTestHelper.userService.billingApi.finalizeTransfer(transferID);
expect(finalizeResponse.status).to.be.eq(StatusCodes.INTERNAL_SERVER_ERROR);
});
+
+ it('should generate a transfer invoice', async () => {
+ const response = await billingTestHelper.userService.billingApi.createSubAccount({
+ businessOwnerID: billingTestHelper.userContext.id
+ });
+ expect(response.status).to.be.eq(StatusCodes.CREATED);
+ // Send the activation link
+ const subAccountOnboardResponse = await billingTestHelper.userService.billingApi.sendSubAccountOnboarding(response.data.id);
+ expect(subAccountOnboardResponse.status).to.be.eq(StatusCodes.OK);
+ expect(subAccountOnboardResponse.data.status).to.be.eq(BillingAccountStatus.PENDING);
+ // Activate the sub account
+ const activationResponse = await billingTestHelper.userService.billingApi.activateSubAccount({ accountID: response.data.id, TenantID: billingTestHelper.tenantContext.getTenant().id });
+ expect(activationResponse.status).to.be.eq(StatusCodes.OK);
+ expect(activationResponse.data.status).to.be.eq(BillingAccountStatus.ACTIVE);
+
+
+ const transfer: BillingTransfer = { ...BillingTransferFactory.build(), status: BillingTransferStatus.DRAFT, accountID: activationResponse.data.id };
+ transfer.id = await BillingStorage.saveTransfer(billingTestHelper.tenantContext.getTenant(), transfer);
+ const finalizeResponse = await billingTestHelper.userService.billingApi.finalizeTransfer(transfer.id);
+ expect(finalizeResponse.status).to.be.eq(StatusCodes.OK);
+ });
});
});

MODIFY
test/api/StripeTestHelper.ts
test/api/StripeTestHelper.ts
-import { BillingAccount, BillingChargeInvoiceAction, BillingInvoice, BillingInvoiceItem, BillingInvoiceStatus, BillingOperationResult, BillingUser, BillingUserData } from '../../src/types/Billing';
+import { BillingAccount, BillingChargeInvoiceAction, BillingInvoice, BillingInvoiceItem, BillingInvoiceStatus, BillingOperationResult, BillingTransfer, BillingUser, BillingUserData } from '../../src/types/Billing';
import { BillingSettings, BillingSettingsType, SettingDB } from '../../src/types/Setting';
import Tenant, { TenantComponents } from '../../src/types/Tenant';
import chai, { expect } from 'chai';
@@ -455,4 +455,8 @@ export default class StripeTestHelper {
public async createSubAccount(): Promise<BillingAccount> {
return await this.billingImpl.createSubAccount();
}
+
+ public async generateTransferInvoice(billingTransfer: BillingTransfer, user: User): Promise<BillingInvoice> {
+ return await this.billingImpl.generateTransferInvoice(billingTransfer, user);
+ }
}

MODIFY
test/factories/BillingFactory.ts
test/factories/BillingFactory.ts
@@ -16,7 +16,8 @@ export const BillingTransferFactory = Factory.define<BillingTransfer>('billingtr
invoiceExternalID: faker.datatype.uuid(),
}))
.attr('sessions', () =>
- [BillingTransferSessionFactory.build()]);
+ [BillingTransferSessionFactory.build()])
+ .attr('currency', () => 'EUR');
export const BillingTransferSessionFactory = Factory.define<BillingTransferSession>('billingtransfersession')
.attr('transactionID', () => faker.datatype.number())

feat(transfer): generate invoice for given transfer